feat: contact form backend for linux and freebsd servers
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-29 00:32:56 +02:00
commit 3a38f00dc0
49 changed files with 10769 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# 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 (nuntius.service) loads this file via
# EnvironmentFile= and exports every variable to nuntius at startup.
# The TOML config substitutes ${VAR_NAME} references from this file
# before parsing, so secrets can stay out of config.toml.
# Required: SMTP password used by every form. Reference in config.toml as
# ${NUNTIUS_SMTP_PASSWORD}. The value below is a placeholder; replace it.
NUNTIUS_SMTP_PASSWORD=admin
+38
View File
@@ -0,0 +1,38 @@
# Race, Go. Dispatched by hand, and run as part of the release gates.
#
# The race detector roughly doubles both time and memory, which the shared runner box
# cannot afford on every push. Locally it belongs to `just gates`, which runs it once per
# task; here it is an explicit decision rather than a routine.
#
# Every step is one command, so the step that fails is the gate that failed.
name: Race
on:
workflow_dispatch:
env:
# interpres is fetched directly from the self-hosted Gitea, not via
# proxy.golang.org, and skips the public checksum database.
GOPRIVATE: sourcedock.dev
# One core: parallelism buys no speed here and costs memory the box does not have.
GOFLAGS: -p=1
GOMAXPROCS: "2"
jobs:
race:
runs-on: fedora
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install gcc
# The race detector needs cgo and the runner image carries no C compiler.
run: dnf install -y gcc
- name: Race
run: go test -race -count=1 -timeout 10m ./...
+379
View File
@@ -0,0 +1,379 @@
# Release, Go binaries. Runs on version tags (v1.2.3) pushed to main.
#
# The version contract these steps implement is in the `release` skill, and its point is
# that nothing is injected: the toolchain records the tag into the binary's build
# information, so the build simply has to happen at the tag, which the trigger guarantees.
#
# The gates run in their own job, once, before the matrix, minus the race detector: race
# never runs on a push path or a tag, and the local gate raced this tree before the tag
# was cut. Putting the gates inside the matrix would run the whole suite once per target
# on the box that also hosts the forge. Each job validates the tag for itself rather than
# passing a value between jobs, so no workflow feature has to be trusted for the version
# to reach the file name.
name: Release
on:
push:
tags: ["v*"]
env:
# interpres is fetched directly from the self-hosted Gitea, not via
# proxy.golang.org, and skips the public checksum database.
GOPRIVATE: sourcedock.dev
# The box is shared with the forge, so parallelism is bounded on purpose. The gates job
# needs it most; the build jobs inherit it for their parallel compilation.
GOFLAGS: -p=1
GOMAXPROCS: "2"
jobs:
gates:
runs-on: fedora
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install Perl
# Perl for the steps below. The install is a no-op where the package
# is already present.
run: dnf install -y perl
- name: Validate the tag
env:
VERSION: ${{ gitea.ref_name }}
run: |
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ m{^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$}
or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n};
print qq{tag $v\n};
'
- name: Build
run: go build ./...
- name: Format
run: |
perl -e '
open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!};
my @bad = <$g>;
close($g);
print @bad;
exit(@bad ? 1 : 0);
'
- name: Vet
run: go vet ./...
- name: Modernise
run: go fix -diff ./...
- name: Tests
# Equal to `packages` in the project's justfile: the logic packages hold the
# floor, and the thin cmd/ would drag it under 80 %.
run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./internal/...
- name: Coverage floor
run: |
perl -e '
open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!};
my $total;
while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} }
close($c);
die qq{no total line in coverage.out\n} unless defined $total;
printf qq{Total coverage: %s%%\n}, $total;
exit($total < 80 ? 1 : 0);
'
build:
runs-on: fedora
timeout-minutes: 25
needs: gates
strategy:
fail-fast: false
matrix:
# Portable targets: amd64, arm64, loong64 and riscv64 on Linux; amd64
# and arm64 on FreeBSD, shipped as cross compiles and runtime untested.
# freebsd/riscv64 is not a supported port and is not shipped. The build
# constraints declare linux and freebsd only, so there is no macOS, no
# Windows, no 32-bit, no wasm. Add GOOS=android GOARCH=arm64 where a
# project ships Android.
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: linux
goarch: loong64
- goos: linux
goarch: riscv64
- goos: freebsd
goarch: amd64
- goos: freebsd
goarch: arm64
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: Install Perl
run: dnf install -y perl
- name: Validate the tag
id: version
env:
VERSION: ${{ gitea.ref_name }}
run: |
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ m{^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$}
or die qq{ERROR: expected a semver tag like v1.2.3, got: $v\n};
(my $nv = $v) =~ s{^v}{};
open(my $o, q{>>}, $ENV{GITEA_OUTPUT}) or die qq{GITEA_OUTPUT: $!};
print $o qq{version_no_v=$nv\n};
close($o);
print qq{version $nv\n};
'
- name: Build
env:
VERSION_NO_V: ${{ steps.version.outputs.version_no_v }}
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
# Nothing is injected. The toolchain records the tag into the binary's build
# information, so the version is right because this build happens at the tag, and
# there is no path for anyone to get wrong. -s -w only strips symbols.
go build -ldflags "-s -w" -o "bin/nuntius-${VERSION_NO_V}-${GOOS}-${GOARCH}" ./cmd/server
# Artefacts stay on v3: v4 and later detect Gitea as GHES and abort.
- name: Upload artefact
uses: actions/upload-artifact@v3
with:
name: nuntius-${{ matrix.goos }}-${{ matrix.goarch }}
path: bin/nuntius-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
if-no-files-found: error
- name: Smoke test
# Only a binary matching the runner can be run here. The check is not that --version
# exits cleanly but that it reports the tag and nothing more: a build outside version
# control reports (devel), and a build whose tree was dirty reports +dirty, and both
# would otherwise be published.
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
env:
TAG: ${{ gitea.ref_name }}
BIN: bin/nuntius-${{ steps.version.outputs.version_no_v }}-${{ matrix.goos }}-${{ matrix.goarch }}
run: |
perl -e '
my $want = $ENV{TAG} // die qq{ERROR: no tag\n};
open(my $bin, q{-|}, $ENV{BIN}, q{--version}) or die qq{$ENV{BIN}: $!};
my $got = <$bin>;
close($bin);
$got = defined $got ? $got : q{};
chomp $got;
index($got, $want) >= 0
or die qq{ERROR: the binary printed "$got", which does not contain $want. Version control was disabled, so there is no recorded version.\n};
index($got, q{+dirty}) < 0
or die qq{ERROR: the binary printed "$got". The tree was dirty at build time, which means the checkout was not the tag, or the build artefacts are not ignored.\n};
print qq{$ENV{BIN} reports $got\n};
'
release:
runs-on: fedora
timeout-minutes: 15
needs: build
permissions:
# contents: read is required for the checkout: a job that declares any
# permissions gets a token scoped to exactly those, and releases: write
# alone leaves the fetch with no read access, which Gitea answers with
# a 404 "Repository not found". Verified on the instance 2026-09-16.
contents: read
releases: write
steps:
- uses: actions/checkout@v7
- name: Download all artefacts
uses: actions/download-artifact@v3
with:
path: dist
- name: Install Perl
run: dnf install -y perl
- name: Extract the CHANGELOG section
env:
VERSION: ${{ gitea.ref_name }}
run: |
# Each step derives what it needs from the tag, so no value has to travel between
# jobs.
perl -e '
my $v = $ENV{VERSION} // q{};
$v =~ s{^v}{};
open(my $vout, q{>}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!};
print $vout $v;
close($vout);
open(my $in, q{<}, q{CHANGELOG.md}) or die qq{CHANGELOG.md: $!};
my @lines = <$in>;
close($in);
my ($start, $end) = (-1, scalar @lines);
for my $i (0 .. $#lines) {
if ($start < 0) { $start = $i if $lines[$i] =~ m{^##\s+\[\Q$v\E\]} }
elsif ($lines[$i] =~ m{^##\s+\[}) { $end = $i; last }
}
$start >= 0 or die qq{ERROR: no CHANGELOG section for $v, expected a heading like: ## [$v] - YYYY-MM-DD\n};
my @body = grep { m{\S} } @lines[$start + 1 .. $end - 1];
@body or die qq{ERROR: the CHANGELOG section for $v is empty\n};
open(my $out, q{>}, q{release-body.md}) or die qq{release-body.md: $!};
print $out @body;
close($out);
printf qq{notes for %s: %d lines\n}, $v, scalar @body;
'
- name: Build the release request
run: |
perl -e '
open(my $vin, q{<}, q{version-no-v.txt}) or die qq{version-no-v.txt: $!};
my $v = <$vin>;
close($vin);
chomp $v;
open(my $in, q{<:raw}, q{release-body.md}) or die qq{release-body.md: $!};
my $body = do { local $/; <$in> };
close($in);
# Byte-oriented escaping: JSON is UTF-8, so non-ASCII passes through and only the
# characters JSON forbids are rewritten.
$body =~ s/([\\"])/\\$1/g;
$body =~ s/\t/\\t/g;
$body =~ s/\r//g;
$body =~ s/\n/\\n/g;
$body =~ s/([\x00-\x08\x0b\x0c\x0e-\x1f])/sprintf(q{\u%04x}, ord($1))/ge;
my $json = sprintf(qq{{"tag_name":"v%s","name":"v%s","body":"%s","draft":false,"prerelease":false}}, $v, $v, $body);
open(my $out, q{>}, q{release.json}) or die qq{release.json: $!};
print $out $json;
close($out);
print qq{release.json written for v$v\n};
'
- name: Create the release
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: |
perl -e '
my @cmd = (q{curl}, q{-sS}, q{-o}, q{response.json}, q{-w}, q{%{http_code}},
q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}},
q{-H}, q{Content-Type: application/json},
q{-X}, q{POST},
qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases},
q{--data-binary}, q{@release.json});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $code = <$curl>;
my $ok = close($curl);
my $exit = $? >> 8;
$code = defined $code ? $code : q{};
$ok or die qq{ERROR: curl failed (exit $exit) calling $ENV{GITEA_SERVER_URL}\n};
open(my $r, q{<:raw}, q{response.json}) or die qq{response.json: $!};
my $body = do { local $/; <$r> };
close($r);
$code eq q{201} or die qq{ERROR: the release was not created, HTTP $code: $body\n};
$body =~ m{"id"\s*:\s*([0-9]+)} or die qq{ERROR: no release id in the response: $body\n};
open(my $o, q{>}, q{release-id.txt}) or die qq{release-id.txt: $!};
print $o $1;
close($o);
print qq{release id $1\n};
'
- name: Upload assets
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
run: |
perl -e '
open(my $f, q{<}, q{release-id.txt}) or die qq{release-id.txt: $!};
my $id = <$f>;
close($f);
chomp $id;
my @files = grep { -f $_ } glob(q{dist/*/*});
@files or die qq{ERROR: no assets under dist/\n};
my $bad = 0;
for my $path (@files) {
(my $name = $path) =~ s{.*/}{};
my @cmd = (q{curl}, q{-sS}, q{-o}, q{/dev/null}, q{-w}, q{%{http_code}},
q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}},
q{-H}, q{Content-Type: application/octet-stream},
q{-X}, q{POST}, q{--data-binary}, q{@} . $path,
qq{$ENV{GITEA_SERVER_URL}/api/v1/repos/$ENV{GITEA_REPOSITORY}/releases/$id/assets?name=$name});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $code = <$curl>;
my $ok = close($curl);
my $exit = $? >> 8;
$code = defined $code ? $code : q{};
unless ($ok) {
printf qq{%s: curl failed (exit %d)\n}, $name, $exit;
$bad = 1;
next;
}
printf qq{%s: HTTP %s\n}, $name, $code;
$bad = 1 if $code ne q{201};
}
exit($bad ? 1 : 0);
'
- name: Read the assets back
# HTTP 201 from the upload alone lies: an attachment can be created
# and still stored empty. Every asset is read back through the release
# download route, and the served length must equal the sent file.
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_SERVER_URL: ${{ gitea.server_url }}
GITEA_REPOSITORY: ${{ gitea.repository }}
TAG: ${{ gitea.ref_name }}
run: |
perl -e '
my @files = grep { -f $_ } glob(q{dist/*/*});
@files or die qq{ERROR: no assets under dist/\n};
my $bad = 0;
for my $path (@files) {
(my $name = $path) =~ s{.*/}{};
my @cmd = (q{curl}, q{-sS}, q{-o}, q{asset-readback.bin},
q{-w}, q{%{http_code} %{size_download}},
q{-H}, qq{Authorization: token $ENV{GITEA_TOKEN}},
qq{$ENV{GITEA_SERVER_URL}/$ENV{GITEA_REPOSITORY}/releases/download/$ENV{TAG}/$name});
open(my $curl, q{-|}, @cmd) or die qq{curl: $!};
my $line = <$curl>;
my $ok = close($curl);
my $exit = $? >> 8;
unless ($ok) {
printf qq{%s: curl failed (exit %d)\n}, $name, $exit;
$bad = 1;
next;
}
$line = defined $line ? $line : q{};
chomp $line;
my ($code, $served) = split q{ }, $line;
$code //= q{};
$served //= 0;
my $sent = -s $path;
unless ($code eq q{200}) {
printf qq{%s: HTTP %s on read-back\n}, $name, $code;
$bad = 1;
next;
}
unless ($served == $sent) {
printf qq{%s: served %s bytes, sent %d\n}, $name, $served, $sent;
$bad = 1;
next;
}
printf qq{%s: read back, %d bytes\n}, $name, $served;
}
exit($bad ? 1 : 0);
'
+96
View File
@@ -0,0 +1,96 @@
# Test, Go. Push and pull request to development. Never on main.
#
# The gates are the ones the justfile's `gates` recipe runs, minus race: the shared
# runner box cannot afford the race detector on every push, so it lives in race.yml.
# The box is one core and 2 GB beside Gitea, so parallelism is bounded on purpose and
# everything runs in one job. Extra jobs would duplicate the checkout, the Go setup and
# the dependency download three times without buying any parallelism.
#
# Every step is one command, so the step that fails is the gate that failed, and no shell
# option has to be trusted for the run to stop. The scripted steps are Perl, not shell and
# not Python: Perl behaves the same on both runner images, there is no bashism to trip over
# on ash, and it is one language instead of two. The Perl uses builtins only, because
# Fedora packages the Perl modules separately and nothing beyond `perl` itself may be
# assumed present.
name: Test
on:
push:
branches: [development]
pull_request:
branches: [development]
env:
# interpres is fetched directly from the self-hosted Gitea, not via
# proxy.golang.org, and skips the public checksum database.
GOPRIVATE: sourcedock.dev
# One core: parallelism buys no speed here and costs memory the box does not have.
GOFLAGS: -p=1
GOMAXPROCS: "2"
# A superseded run of the same ref is cancelled instead of queueing behind one that
# no longer matters. Verified on Gitea 1.27.1 on 2026-09-17: a queued run whose ref
# moved on is cancelled before it ever reaches the runner, while a run already
# dispatched there runs to completion.
concurrency:
group: ${{ gitea.workflow }}-${{ gitea.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: fedora
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
# The module is the source of truth for the version, so it cannot drift.
go-version-file: go.mod
cache: true
- name: Install Perl
# The runner images are minimal and Perl is not guaranteed. The install is a
# no-op where it is already present; drop this step once verified on the box.
run: dnf install -y perl
# The steps follow the `gates` order of the justfile contract: build, format,
# vet, test. The vet gate is go vet and go fix -diff, two steps here.
- name: Build
run: go build ./...
- name: Format
run: |
perl -e '
open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!};
my @bad = <$g>;
close($g);
print @bad;
exit(@bad ? 1 : 0);
'
- name: Vet
run: go vet ./...
- name: Modernise
# Exits non-zero when it has something to rewrite, so it needs no output capture.
run: go fix -diff ./...
- name: Tests
# Equal to `packages` in the project's justfile: the logic packages hold the
# floor, and the thin cmd/ would drag it under 80 %.
# The inner timeout matches the job's, so a hanging test reports its own
# goroutine dump rather than a silent job kill.
run: go test -count=1 -timeout 10m -coverprofile=coverage.out ./internal/...
- name: Coverage floor
run: |
perl -e '
open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!};
my $total;
while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} }
close($c);
die qq{no total line in coverage.out\n} unless defined $total;
printf qq{Total coverage: %s%%\n}, $total;
exit($total < 80 ? 1 : 0);
'
+13
View File
@@ -0,0 +1,13 @@
.idea/
.zcode/
# Build output
bin/
*.out
coverage.html
*.test
# Local secrets and config
.env*
!.env.example
config.toml
+107
View File
@@ -0,0 +1,107 @@
# 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).
## [0.1.0] - 2026-09-29
### Added
- **A contact form backend in one static binary.** nuntius serves JSON form
endpoints, delivers the submissions by SMTP and writes structured JSON logs.
No Node, no Python, no Redis, no Postgres, and no third-party Go module
beyond the first-party [`interpres`](https://sourcedock.dev/petrbalvin/interpres)
TOML parser.
- **Plain HTML form posts, no JavaScript required.** Every endpoint accepts
`application/x-www-form-urlencoded` and `multipart/form-data` under the
fixed field names `name`, `email`, `service` and `message` beside the JSON
contract, and a form with `redirect_url` set answers every accepted
submission, honeypot hits included, with `303 See Other` to that page.
Failures stay machine-readable JSON. Body-parse rejections report
`invalid_body` across every content type.
- **Four form types**: `contact`, `feedback`, `newsletter` and `generic`. Each
form has its own endpoint, rate limit, CORS allowlist, honeypot field and
SMTP credentials, so one process serves many independent forms.
- **Declarative validation policy.** A form's rules are configuration:
`services` puts an allow-list on the optional `service` payload field of any
form type (the empty value always passes, the `*` entry accepts any value,
an explicit empty list accepts only the empty value), `require_name` and
`require_message` move the free-text fields in and out of the checks, and
`min_name_runes`, `max_name_runes`, `min_message_runes`, `max_message_runes`
size them. A new form shape is a matter of `config.toml`, never of Go code.
- **Newsletter double opt-in.** A signup stays pending and the subscriber
receives a confirmation mail with a single-use link
(`GET <form path>/confirm?token=...`); only a redeemed link writes the
address into the JSONL log and notifies the owner. Unconfirmed entries
expire after `pending_ttl_seconds` (72 hours by default), tokens are
256-bit random values stored as SHA-256 hashes, and replaying a used link
returns `410 Gone`. A repeat signup of an already recorded address gets the
same `200 ok` response with no second mail and no duplicate record.
- **Abuse defence without CAPTCHA.** A per-IP token bucket bounds each form
(capped at `server.rate_limit_max_buckets` distinct IPs, unknown IPs denied
while full), a configurable honeypot field silently drops bot submissions,
per-form CORS closes cross-site posts, and proxy headers are trusted only
with the explicit `server.trust_proxy_headers` opt-in, so a direct caller
cannot rotate identities to dodge the per-IP limit.
- **Rate-limit state survives graceful restarts.** On shutdown the per-IP
buckets are snapshotted to `data_dir/ratelimit-snapshot.json` (atomic write,
mode `0600`) and restored at startup; stale entries are dropped and a
missing or corrupt file behaves like an empty one.
- **Optional submission archive.** A form with `archive = true` writes every
accepted submission to `data_dir/archive-<name>.jsonl` before the mail is
attempted, so a failed SMTP round-trip loses nothing; a failed append
blocks the send, so a retry cannot split the mail from its record.
Newsletter forms reject the key because they persist through the double
opt-in log already.
- **Optional automated receipt to the submitter.** A form with
`auto_reply = true` mails the submitter a short confirmation with the
owner's address as `Reply-To`; the receipt is best effort and its failure
never turns an accepted submission into an error. Newsletter forms reject
the key because their subscribers already receive the confirmation mail.
- **Telegram notifications.** A per-form `[forms.telegram]` channel
(`bot_token`, `chat_id`, optional `timeout_seconds`) posts the submission
summary into the owner's chat beside the mail. The submission counts as
delivered when either channel gets through: an SMTP outage does not
silence the bell, and only when both fail does the caller see
`send_failed`. The notification is one-way; newsletter forms reject the
key because their double opt-in flow is mail-native.
- **Operational surface.** `GET /health` reports the listener and the form
count, `GET /metrics` reports lifetime counters per form and in total
(optionally guarded by a `server.metrics_token` bearer credential),
`--check-config` validates the configuration file and exits nonzero on any
error (so a systemd `ExecStartPre` can refuse to start on a broken config),
and the `NUNTIUS_CONFIG` environment variable moves the configuration file
out of `/etc/nuntius/` for development.
- **Every operational limit is a configuration key.** `server.bind`,
`server.read_header_timeout_seconds`, `server.read_timeout_seconds`,
`server.write_timeout_seconds`, `server.idle_timeout_seconds`,
`server.shutdown_timeout_seconds`, `server.max_body_bytes`,
`server.rate_limit_max_buckets`, `server.rate_limit_cleanup_seconds`,
`server.rate_limit_max_bucket_age_seconds`, and per form
`pending_ttl_seconds` plus `smtp.timeout_seconds`. Every key is optional
with its default documented in `docs/CONFIGURATION.md`.
- **SMTP delivery with a TLS policy.** PLAIN auth over STARTTLS, implicit TLS
on port `465` (encrypted from the first byte), the `smtp.require_tls` switch
that aborts delivery when a STARTTLS port never upgrades, and a configurable
conversation timeout. Secrets stay out of `config.toml` through `${VAR}`
expansion, and an undefined variable aborts startup instead of surfacing
later as a failed SMTP auth.
- **Messages are hardened at composition.** CR/LF sequences reaching `From`,
`To`, `Subject` or `Reply-To` are replaced with spaces inside the SMTP
builder itself, and the MIME boundary is random per message with a failure
aborting the send rather than falling back to a fixed boundary.
- **Configuration fails loudly at startup.** Unknown TOML fields and unknown
form types are rejected, ports and limits are range-checked, form `path`
values are validated as static route patterns so a mistyped path cannot
widen into a wildcard, and form names are restricted to `[a-zA-Z0-9_-]` so
a crafted name cannot escape the data directory.
- **The release identity comes from the toolchain.** `--version` prints the
tag the binary was built at, a pseudo-version naming the commit otherwise,
and `(devel)` outside version control, with `+dirty` appended on a dirty
tree. Nothing is injected at build time.
- **Binaries for Linux and FreeBSD.** Every release ships `linux/amd64`,
`linux/arm64`, `linux/loong64` and `linux/riscv64`, plus `freebsd/amd64` and
`freebsd/arm64` as cross compiles; the FreeBSD binaries are runtime
untested.
+129
View File
@@ -0,0 +1,129 @@
# Contributing
Contributions to **nuntius** are governed by the Contributor terms
below; submitting one means you accept them.
## Contributor terms
1. This project belongs to its owner alone. The owner decides what is
accepted, in what form and when; the decision is final and needs no
justification.
2. By submitting a contribution you assign to Petr Balvín
<opensource@petrbalvin.org> all present and future copyright and
related rights in it, worldwide, for the full term of the rights,
with the right to relicense and sublicense without restriction,
including under proprietary terms.
3. Where that assignment is not effective, it counts as a perpetual,
irrevocable, royalty-free licence with the same scope.
4. To the fullest extent permitted by law, you waive any right of
attribution and integrity in the contribution. The project names no
contributors and keeps no credits list.
5. By submitting you represent that the work is yours and that you
hold the rights to assign it as above.
## Development setup
Requirements: Go 1.27.1 (the version recorded in `go.mod`), and
[just](https://github.com/casey/just) for the recipes. The race detector in
`just gates` needs a C compiler, so gcc must be installed. nuntius builds
and tests on Linux and FreeBSD.
```sh
git clone https://sourcedock.dev/petrbalvin/nuntius.git
cd nuntius
just build
just test
```
The automated suite is hermetic: the SMTP tests run against in-process fake
servers and the storage tests against temporary directories, so no network
or SMTP account is needed to develop. A local SMTP account only matters for
manual end-to-end checks.
## Workflow
1. Branch from `development`. Never commit directly to `main`, which is release-only.
2. Commit in [Conventional Commits](https://www.conventionalcommits.org/) form:
`type(scope): description`, subject line only, imperative mood, lowercase after the
colon, no trailing full stop. Allowed types: `feat`, `fix`, `docs`, `style`,
`refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`.
3. One logical change per commit. A refactor, a behaviour change and a formatting pass
are three commits, never one.
4. Record every user-visible change in `CHANGELOG.md` under `## [development]`.
5. Add or update tests. Coverage stays at 80 percent or more; it is a hard gate.
6. Update the documentation when the public API, the configuration or the behaviour
changes: `README.md` for the overview, `docs/CONFIGURATION.md` for keys,
`docs/API.md` for endpoints, `docs/ARCHITECTURE.md` for structure,
`docs/DEVELOPMENT.md` for tooling.
7. Never commit while `just gates` is red; run it locally first.
8. Open a pull request against `development`.
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`. The release
workflow builds the assets and publishes the release and its notes.
## Code style
`gofmt` and `go vet` run through `just fmt` and `just vet`, with zero diff and zero
warnings tolerated. `just vet` also runs `go fix -diff`, so modernisations are part of
the gate and not a follow-up. `just gates` is the definition of done in one command,
and the recipe file names what it contains. Errors are checked explicitly, wrapped as
`fmt.Errorf("context: %w", err)`, and nothing panics outside `main`. Error messages
are English, lowercase, with no trailing full stop, and SMTP credentials or full
request bodies never appear in a log line.
New source files open with the project's two-line licence header, whose SPDX
identifier matches `LICENSE`. Configuration files, workflows and dotfiles do not carry
it.
## AI contribution policy
AI tools are welcome as productivity aids and are a normal part of modern software
development. What matters is that the contribution stays understandable, reviewable and
genuinely useful.
- **Disclose the assistance.** If AI helped draft any part of a commit, issue, pull
request or review, say so.
- **Commit messages carry exactly one trailer**, on the line after the subject:
```
Assisted-by: MODEL
```
Name the model that did the work, spelled the way its maker spells it, for example
`GLM 5.3`, `DeepSeek V4.1 Flash` or `Qwen 3.8 Flash`. No `Co-Authored-By`, no `Signed-off-by`,
no other trailers, and no prose: the trailer is the disclosure.
- **Issues and pull requests** attribute the assistance in a comment, for example
`_Assisted-by: GLM 5.3 Flash_`. It does not belong in the pull request description.
- **Take responsibility.** You are accountable for the accuracy, completeness and
intent of everything you submit, whether or not AI produced it.
- **Review before marking ready.** Read the diff carefully, run it locally, and add the
tests it needs. Do not mark a pull request ready until you can defend every change in
it.
- **Quality over quantity.** Contributions that look like un-reviewed output, or whose
author cannot engage substantively during review, may be closed.
- **Preferred models.** Prefer open-weight models with transparent training data and
minimal output filtering.
AI assists. It does not replace judgement.
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners:
| Workflow | Trigger | What it does |
|---|---|---|
| Test | push or pull request to `development` | build, format check, vet, modernisation, the test suite with the coverage floor |
| Race | dispatched by hand | the suite under the race detector, as a second opinion after the local gate |
| Release | a `v*` tag | the same gates as Test, then the matrix build, the proven version and the release itself; the race detector runs locally in `just gates` before the tag is cut |
The local equivalent is `just gates`, which is the same set plus the race detector.
## Reporting bugs
Open an issue at <https://sourcedock.dev/petrbalvin/nuntius/issues> with the
version (`bin/nuntius --version`), the operating system and architecture, the
exact command or request, the full output, and the expected against the
actual behaviour.
**Security issues do not go in the issue tracker.** Report them as
[SECURITY.md](SECURITY.md) describes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+170
View File
@@ -0,0 +1,170 @@
# Nuntius
A small contact form backend for Linux and FreeBSD servers, made to run
behind [Caddy](https://caddyserver.com/) on a modest server: Caddy ends the
TLS and serves the form from the same origin, nuntius carries the messages.
Latin *nuntius* means "messenger": the service carries messages from web
visitors to your inbox. It serves multiple JSON form endpoints (contact,
feedback, newsletter, generic) from a single static binary, delivers
submissions via SMTP, and optionally persists newsletter signups to an
append-only JSONL log.
## Features
- **Single static binary**: roughly 6 MB stripped; no Node, no Python, no
Redis, no Postgres, and no third-party Go module beyond the first-party
[`interpres`](https://sourcedock.dev/petrbalvin/interpres) TOML parser.
- **Four form kinds**: `contact`, `feedback`, `newsletter`, and `generic`.
Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field.
- **No-JavaScript forms**: endpoints also accept plain `urlencoded` and
`multipart` posts and can answer `303 See Other` to a thank-you page, so an
ordinary HTML `<form>` works with no script at all.
- **SMTP delivery**: Go stdlib `net/smtp` with `PLAIN` auth over STARTTLS
(upgraded automatically when the server advertises it, credentials never
sent in plaintext), implicit TLS on port `465`, and an optional
`require_tls` policy that aborts delivery against servers without TLS.
- **Newsletter double opt-in**: a signup stays pending until the subscriber
clicks the confirmation link mailed to them; only confirmed addresses land
in the JSONL log, unconfirmed ones expire, and repeat signups are silently
skipped.
- **Optional submission archive**: a per-form switch logs every accepted
submission to an append-only JSONL file before the mail goes out, so a
failed SMTP round-trip loses nothing.
- **Optional submitter receipt**: `auto_reply` mails the sender a short
automated acknowledgement with the owner's address as `Reply-To`.
- **Telegram notifications**: a per-form `[forms.telegram]` channel posts the
summary into your chat beside the mail; the submission counts as delivered
when either channel gets through, so an SMTP outage does not silence the
bell.
- **Server-side validation**: every field is checked before anything is
delivered: name, email (RFC 5322), an allow-listed optional `service`
field, and message length. Every limit is a configuration key, and the
server builds each form's policy from `config.toml`.
- **Token-bucket rate limiting**: per IP, per form, configurable submissions
per hour, capped in memory, persisted across graceful restarts.
- **Honeypot field**: invisible to humans, required by bots. Silently accepts
and drops spam submissions.
- **CORS allowlists**: per form, explicit origins only; disallowed origins
receive `403 Forbidden`.
- **Bounded requests**: every timeout, the body cap and the graceful shutdown
deadline are configuration keys; oversized bodies get `413`.
- **Operational endpoints**: `GET /health` and `GET /metrics` with lifetime
counters per form, optionally guarded by a bearer token.
- **Structured logging**: `log/slog` with JSON output to stdout; credentials
are never logged.
- **TOML configuration**: single file, parsed by the first-party `interpres`
library. Unknown fields and unknown form types are rejected at startup so
typos fail loudly.
- **Environment variable expansion**: `${VAR_NAME}` and `$VAR_NAME` in the
TOML file keep SMTP credentials out of version control.
- **IPv6-first**: binds to `[::]` by default, with automatic IPv4
compatibility via dual-stack sockets.
- **Cross-platform**: pre-built binaries for Linux (amd64, arm64, loong64,
riscv64) and FreeBSD (amd64, arm64) on every release. The FreeBSD binaries
ship as cross compiles and are runtime untested.
## Install
Prebuilt binaries for Linux and FreeBSD are on the
[releases page](https://sourcedock.dev/petrbalvin/nuntius/releases). From
source:
```sh
git clone https://sourcedock.dev/petrbalvin/nuntius.git
cd nuntius
just build # produces bin/nuntius
```
## Quick start
```sh
# 1. Point the server at a writable config path and satisfy the secret
# referenced by the starter template.
export NUNTIUS_CONFIG=./config.toml
export NUNTIUS_SMTP_PASSWORD=admin
# 2. Build and run; the first start writes a three-form starter config.
just build
just run # listens on [::]:8080
# 3. Verify.
curl http://127.0.0.1:8080/health
```
Edit the generated `./config.toml` with real SMTP credentials and recipient
addresses before exposing the service. In production the default location is
`/etc/nuntius/config.toml`; see [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md)
for the full setup. The full schema lives in
[`docs/CONFIGURATION.md`](docs/CONFIGURATION.md).
## Usage
Run the server:
```sh
export NUNTIUS_CONFIG=./config.toml # omitted: /etc/nuntius/config.toml
bin/nuntius # serves until SIGINT or SIGTERM
```
Validate a configuration without listening, as a systemd `ExecStartPre`
would:
```sh
NUNTIUS_CONFIG=./config.toml bin/nuntius --check-config
```
Post a submission:
```sh
curl -X POST http://127.0.0.1:8080/api/nuntius/contact \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{"name":"Jane Doe","email":"jane@example.com","message":"Hello"}'
# → 200 {"ok": true}; the mail lands in the form's recipient address
```
A plain HTML form posts the same fields urlencoded and, with `redirect_url`
set, receives `303 See Other` to its thank-you page. The complete endpoint
reference is [`docs/API.md`](docs/API.md); the flags and exit codes are in
[`docs/CLI.md`](docs/CLI.md).
Nuntius speaks plain HTTP by design: put Caddy in front of it for TLS, a
same-origin form endpoint and client IPs you can trust. Two lines in the
Caddyfile are enough:
```caddyfile
handle_path /api/nuntius/* {
reverse_proxy 127.0.0.1:8080
}
```
Caddy overwrites `X-Forwarded-For` for untrusted clients, so with
`server.trust_proxy_headers = true` the rate limiter and the logs see the
real visitor. The full production setup, including the systemd unit, is in
[`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md).
## Development
```sh
just build # build
just test # the test suite with the coverage floor
just fmt # format
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full workflow, and
[CONTRIBUTING.md](CONTRIBUTING.md) for how to contribute.
## Documentation
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): components and data flow
- [docs/API.md](docs/API.md): the API reference
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md): every configuration key
- [docs/CLI.md](docs/CLI.md): flags, exit codes, and the manual page
- [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md): production setup, Caddy, updates
- [SECURITY.md](SECURITY.md): how to report a vulnerability
## Licence
MIT, see [LICENSE](LICENSE).
Copyright © 2026 [Petr Balvín](https://petrbalvin.org)
+55
View File
@@ -0,0 +1,55 @@
# Security policy
## Supported versions
Security fixes go to the newest release and to the `development` branch. Older releases
do not receive them.
| Version | Supported |
|---|---|
| 1.0.x | yes |
| older releases | no |
## Reporting a vulnerability
**Do not open a public issue for a security problem.** A public report tells everyone
about the flaw before there is a fix. Report it privately to
**opensource@petrbalvin.org**.
Include:
- the version or commit you tested, and the platform
- what the problem is, and what an attacker gains from it
- the smallest reproducer you have, ideally a test or a single command
- a suggested fix, if you have one
## What to expect
- A human reads the report, and you get an acknowledgement.
- You are kept informed while the fix is being made, and told when it ships.
- The fix is released before the details are published, and the timing is agreed with
you.
- The reporter is credited in the release notes unless they ask to remain anonymous.
## Out of scope
- Findings that require the attacker to already run code as the user, or to have local
access.
- Missing hardening with no demonstrated impact.
- Flaws in a third-party dependency: report them to that project, and to this one only
when this project's use of it makes them reachable.
- Bypassing the per-IP rate limit from a rotating pool of addresses. nuntius has no
CAPTCHA, no proof-of-work and no global rate limit; put a reverse proxy with those
capabilities in front of it.
- Abuse from a compromised frontend that posts valid, allowlisted traffic at the
configured rate. The token bucket bounds the volume; nothing else can distinguish it
from real users.
- A compromised server. nuntius reads the SMTP password from the process environment,
so anyone with root or `ps` access can read it from `/proc/<pid>/environ`.
- Downgrade of the SMTP transport on a STARTTLS port. Port 587 upgrades opportunistically
by design; set `smtp.require_tls = true` to make the upgrade a hard requirement.
- Absence of DKIM signing and request signing. Outbound mail is signed by the SMTP
provider, and message authenticity rests on the origin allowlist and the honeypot.
- Content carried by a configured Telegram channel. The submission summary reaches
Telegram's servers, exactly as mail reaches the SMTP provider's; forms that must
not share content with Telegram simply do not configure the channel.
+145
View File
@@ -0,0 +1,145 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//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"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/handler"
"sourcedock.dev/petrbalvin/nuntius/internal/version"
)
func main() {
showVersion := flag.Bool("version", false, "Print version and exit")
checkConfig := flag.Bool("check-config", false, "Validate the configuration file and exit without listening")
flag.Parse()
if *showVersion {
fmt.Printf("%s %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()
if *checkConfig {
if _, err := config.Load(cfgPath); err != nil {
fmt.Fprintf(os.Stderr, "nuntius: configuration %s is invalid: %v\n", cfgPath, err)
os.Exit(1)
}
fmt.Printf("configuration OK: %s\n", cfgPath)
return
}
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{
// The bind host and every timeout are configuration: an omitted
// key resolves to the value this release has always used, so the
// zero-config behaviour is unchanged.
Addr: net.JoinHostPort(cfg.Server.Bind, strconv.Itoa(cfg.Server.Port)),
Handler: withRequestLog(mux, logger, cfg.Server.TrustProxyHeaders),
ReadHeaderTimeout: cfg.Server.ReadHeaderTimeout(),
ReadTimeout: cfg.Server.ReadTimeout(),
WriteTimeout: cfg.Server.WriteTimeout(),
IdleTimeout: cfg.Server.IdleTimeout(),
}
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(), cfg.Server.ShutdownTimeout())
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("shutdown error", "err", err)
h.PersistState()
os.Exit(1)
}
h.PersistState()
h.Close()
logger.Info("nuntius stopped cleanly")
}
// withRequestLog logs each HTTP request method, path, status, and duration.
// The logged IP follows the same trust rules as rate limiting: proxy
// headers only when cfg says a trusted proxy is in front.
func withRequestLog(next http.Handler, logger *slog.Logger, trustProxy bool) 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", handler.ClientIP(r, trustProxy),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
+52
View File
@@ -0,0 +1,52 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package main
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
)
func TestStatusRecorder(t *testing.T) {
rec := httptest.NewRecorder()
sr := &statusRecorder{ResponseWriter: rec, status: http.StatusOK}
// Default status before WriteHeader.
if sr.status != http.StatusOK {
t.Errorf("initial status = %d, want %d", sr.status, http.StatusOK)
}
sr.WriteHeader(http.StatusNotFound)
if sr.status != http.StatusNotFound {
t.Errorf("status after WriteHeader = %d, want %d", sr.status, http.StatusNotFound)
}
// rec should also have the status set.
if rec.Code != http.StatusNotFound {
t.Errorf("rec.Code = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestWithRequestLog(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
wrapped := withRequestLog(handler, logger, false)
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.RemoteAddr = "192.168.1.1:12345"
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
+188
View File
@@ -0,0 +1,188 @@
# API
The HTTP surface of nuntius. Every form defined in `config.toml` exposes
the same verbs on its configured `path`; the server adds two global
endpoints. There is no authentication on the form endpoints: the browser
contract is the per-form CORS allowlist, and `GET /metrics` optionally
carries a bearer token.
## HTTP API
| Method | Path | Purpose |
|---|---|---|
| `POST` | `<form path>` | validate, honeypot-check, deliver (mail plus the optional Telegram notification); newsletter forms start the double opt-in |
| `OPTIONS` | `<form path>` | CORS preflight |
| `GET` | `<form path>/confirm` | redeem a newsletter double opt-in token |
| `GET` | `/health` | listener liveness and the form count |
| `GET` | `/metrics` | lifetime counters per form and in total |
Any other method on a form path answers `405 Method Not Allowed`.
### `POST <form path>`
Two body shapes carry the same fields. The JSON contract is one JSON
object per request. The plain HTML form contract accepts
`application/x-www-form-urlencoded` and `multipart/form-data` posts under
the fixed field names `name`, `email`, `service` and `message` plus the
configured honeypot field, so an ordinary `<form method="post">` works
without JavaScript; file parts are ignored.
| Field | Required | Notes |
|---|---|---|
| `name` | `contact`, `feedback`, `generic` presets; tunable with `require_name` | 2 to 100 runes by default |
| `email` | always | RFC 5322, never length-limited |
| `service` | optional | checked against the form's allow-list |
| `message` | `contact`, `feedback`, `generic` presets; tunable with `require_message` | 10 to 5,000 runes by default |
```sh
curl -X POST http://localhost:8080/api/nuntius/contact \
-H "Content-Type: application/json" \
-H "Origin: https://example.com" \
-d '{"name":"Jane Doe","email":"jane@example.com","service":"architecture","message":"Hello, I would like to discuss an engagement."}'
# → 200 {"ok": true}
```
```sh
curl -i -X POST http://localhost:8080/api/nuntius/contact \
-d 'name=Jane Doe&email=jane@example.com&message=Hello, I would like to discuss an engagement.'
# → 303 See Other, Location: https://example.com/thanks (when redirect_url is set)
```
| Status | Body | When |
|---|---|---|
| `200` | `{"ok": true}` | Submission accepted and delivered. A form with `forms.telegram` counts as delivered when either the mail or the chat message gets through |
| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail, no log) |
| `303` | _redirect_ | A form with `redirect_url` set answers every accepted submission, honeypot hits and duplicate signups included, with `303 See Other` to the configured page |
| `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid |
| `400` | `{"error": "invalid_body", "message": "..."}` | The body is unparsable: not valid JSON, or a broken form body |
| `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` |
| `413` | `{"error": "body_too_large", "message": "..."}` | Body exceeds `server.max_body_bytes` |
| `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form |
| `500` | `{"error": "send_failed", "message": "..."}` | Both delivery channels failed: the SMTP round-trip and, where configured, the Telegram notification |
| `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter`, or any form with `archive` set: could not write the JSONL line |
The error bodies are one shape: `error` is a machine-readable code
(`validation`, `invalid_body`, `origin_not_allowed`, `rate_limited`,
`send_failed`, `storage_failed`), `message` a human-readable summary, and
`details[]` carries the per-field validation failures:
```json
{
"error": "validation",
"details": [
{ "field": "name", "message": "name must be at least 2 characters" },
{ "field": "email", "message": "email is invalid" }
]
}
```
Rate limiting is a per-IP token bucket per form: the bucket size equals
`rate_limit_per_hour`, it refills at `perHour / 3600` tokens per second,
and `rate_limit_per_hour: 0` disables it. State is per-process and survives
graceful restarts via the snapshot file under `data_dir/`; scaled
horizontally, each replica has its own bucket. The IP is the connection
peer address unless `server.trust_proxy_headers` opts in to proxy headers.
#### CORS
A browser preflight is an `OPTIONS` request with `Origin` and
`Access-Control-Request-Method`; an allowed origin gets `204` with
`Access-Control-Allow-Origin` echoed, `POST, OPTIONS` methods,
`Content-Type` allowed and `Vary: Origin`. A plain HTML form post carries
an `Origin` header like any browser POST, so a same-origin `<form>` lists
its own origin in `allowed_origins` too. An absent `Origin` header is
allowed, which makes `curl` work out of the box; a present origin that is
not allowlisted gets `403`. Wildcard `*` is not supported.
### `GET <form path>/confirm`
Newsletter forms expose this endpoint next to their POST route; the link
inside every confirmation mail points here, and the response is a small
HTML page.
| Outcome | Status | Page |
|---|---|---|
| Valid, unexpired token | `200` | "Subscription confirmed"; the address moves into the subscriber log and the owner is notified |
| Unknown, already redeemed or expired token | `410` | "Link expired", inviting a fresh signup |
| Storage failure while saving the record | `500` | "Almost there", the link stays usable for a retry |
Tokens are single-use 256-bit random values; only their SHA-256 hashes are
stored server-side.
#### Flow
```mermaid
sequenceDiagram
participant Subscriber
participant Nuntius
participant Mailbox as Subscriber's inbox
Subscriber->>Nuntius: POST <form path> (email)
Nuntius->>Nuntius: store pending (SHA-256 token)
Nuntius-->>Subscriber: 200 ok
Nuntius->>Mailbox: confirmation link
Subscriber->>Nuntius: GET <form path>/confirm?token=...
Nuntius->>Nuntius: append subscriber, notify owner
Nuntius-->>Subscriber: 200 confirmed page
```
### `GET /health`
```sh
curl -s http://localhost:8080/health
# → {"status":"ok","forms":3}
```
`status` is always `"ok"` while the process is up; there is no deep health
check. `forms` is the number of forms loaded from `config.toml`. The
endpoint is not rate-limited and is not CORS-checked.
### `GET /metrics`
```sh
curl -s -H "Authorization: Bearer $NUNTIUS_METRICS_TOKEN" http://localhost:8080/metrics
```
```json
{
"totals": { "received": 42, "honeypot_blocked": 7, "rate_limited": 3, "origin_blocked": 1, "body_too_large": 0, "invalid_body": 2, "validation_failed": 5, "send_failed": 4, "persist_failed": 0, "duplicate_signup": 1, "sent": 19, "auto_reply_failed": 0, "telegram_failed": 0 },
"forms": {
"/api/nuntius/contact": { "received": 30, "honeypot_blocked": 5, "rate_limited": 2, "origin_blocked": 1, "body_too_large": 0, "invalid_body": 1, "validation_failed": 3, "send_failed": 4, "persist_failed": 0, "duplicate_signup": 0, "sent": 15, "auto_reply_failed": 0, "telegram_failed": 0 }
}
}
```
| Counter | Meaning |
|---|---|
| `received` | POST requests that passed the origin check |
| `honeypot_blocked` | Bot submissions dropped by the honeypot field |
| `rate_limited` | Requests rejected with `429 rate_limited` |
| `origin_blocked` | Requests rejected with `403 origin_not_allowed` |
| `body_too_large` | Requests rejected with `413 body_too_large` |
| `invalid_body` | Requests rejected because the body was unreadable or unparsable, whichever shape it claimed |
| `validation_failed` | Requests rejected with field-level `400 validation` errors |
| `send_failed` | Deliveries where neither channel got through |
| `persist_failed` | Newsletter submissions or archive writes whose disk append failed |
| `duplicate_signup` | Newsletter signups skipped because the address is already recorded |
| `confirmation_sent` | Newsletter signups whose confirmation link was mailed |
| `confirmed` | Confirmation links successfully redeemed |
| `confirmation_failed` | Confirmations that could not be saved, or clicked after expiry or unknown |
| `sent` | Completed owner notifications: the submission mail for non-newsletter forms |
| `auto_reply_failed` | Receipts to the submitter that failed after an accepted submission |
| `telegram_failed` | Telegram notifications that failed; the submission stays delivered when the mail went out |
Counters are lifetime values held in memory; they reset on restart, the
same trade-off as the rate-limit buckets. The endpoint is not
rate-limited and sends no CORS headers, so third-party pages cannot read
submission volumes. With `server.metrics_token` set it requires that
token as a bearer credential and answers `401` with a
`WWW-Authenticate: Bearer` challenge otherwise; without the key it is
open, protected at the reverse proxy like any operational surface.
## Notes
- Request bodies are bounded by `server.max_body_bytes` (1 MiB by default)
and read fully into memory; for a 5,000-rune message this is negligible.
- Logs are one JSON line per request (`method`, `path`, `status`,
`duration_ms`, `ip`) via `log/slog`, plus one line per sent or failed
message and one per honeypot hit. No PII beyond the client IP, and no
credentials anywhere.
+122
View File
@@ -0,0 +1,122 @@
# Architecture
How nuntius is put together. Every node, package and arrow below exists in the
source tree; nothing is aspirational.
## Overview
```mermaid
flowchart TD
Browser[Browser / Frontend] --> Caddy[Caddy reverse proxy]
Caddy --> CLI[cmd/server/main.go]
CLI --> Config[internal/config]
CLI --> Handler[internal/handler]
Handler --> Validate[internal/contactform]
Handler --> Email[internal/email]
Handler --> Telegram[internal/telegram]
Handler --> Storage[internal/storage]
Email -->|SMTP PLAIN| SMTP[(SMTP server)]
Telegram -->|Bot API| Chat[(Telegram chat)]
Storage -->|append JSONL| Disk[(data_dir)]
```
One process serves many forms. The entry point loads the configuration and
builds a `ContactHandler`; every request then moves through one pipeline:
CORS, rate limit, parse, honeypot, validate, deliver, persist. The layers
below the handler know nothing about HTTP, and the handler knows nothing
about SMTP or the Bot API beyond two small interfaces.
## Packages
| Package | Responsibility |
|---|---|
| `cmd/server` | Orchestration only: slog setup, config load, handler build, route registration, request-log middleware, the `http.Server` with its configured timeouts, and the graceful shutdown on SIGINT or SIGTERM. No business logic. |
| `internal/config` | The gatekeeper for everything that varies per deployment. `${VAR}` expansion runs on the raw text before `interpres` parses it, unknown fields are rejected, and `validate()` enforces required fields, unique static paths, known form types and sane policy values. `(*Form).Policy()` resolves a form's validation policy: the built-in preset of its type with the configured `services`, `require_*` and rune-limit keys applied on top. Optional keys are pointers, so an omitted key falls back to its default while an explicit zero keeps its documented meaning. |
| `internal/contactform` | The request types and the one validator in the codebase, `Validate(req, policy)`, plus `Preset(formType)` and the `NormalizeAndValidate` preset wrapper. No awareness of HTTP, SMTP, the filesystem, or the platform. |
| `internal/handler` | The HTTP pipeline: per-form routes, CORS, rate limiting, honeypot, delivery fan-out, double opt-in confirmation, metrics registry, and the state persistence across restarts. It owns no delivery or storage logic; it calls the interfaces below. |
| `internal/email` | MIME composition (multipart/alternative, per-type templates, sanitised headers, randomised boundary) and deadline-bounded SMTP delivery with per-form credentials. STARTTLS, implicit TLS on port 465 and the `require_tls` policy are the operator's transport choices, enforced in code. |
| `internal/telegram` | One-way submission summaries through the Bot API, plain text, the bot token redacted from every error. |
| `internal/storage` | The append-only JSONL stores: newsletter subscribers with a dedupe index, pending double opt-in tokens, and the optional submission archive. Malformed lines are skipped on read, never fatal. |
| `internal/version` | The release identity read from the build information; nothing is injected. |
A new form type is a preset in `contactform.Preset`, a subject and a
`compose` branch in `internal/email`, and the wiring in `internal/config`;
every other layer is type-agnostic.
## Data flow
```mermaid
sequenceDiagram
participant Client
participant Handler as ContactHandler
participant Limiter as rateLimiter
participant Validate as contactform.Validate
participant Sender as email.FormSender
participant Bell as telegram.Notifier
participant Store as storage
Client->>Handler: POST <form path>
Handler->>Handler: CORS check
Handler->>Limiter: allow(ip)
Handler->>Handler: parse body (JSON, urlencoded or multipart)
Handler->>Handler: honeypot non-empty?
Handler->>Validate: Validate(req, form.Policy())
Handler->>Store: archive append (when enabled)
Handler->>Sender: Send(req)
Handler->>Bell: Notify(form, req) (when configured)
Handler-->>Client: 200 ok / 303 redirect / 4xx / 5xx
```
The pipeline is short-circuiting, and each step turns its failure into the
response the caller sees: a disallowed origin into `403 origin_not_allowed`,
an empty bucket into `429 rate_limited`, an oversized or unparsable body
into `413 body_too_large` or `400 invalid_body`, failed validation into
`400 validation` with per-field details, a failed archive append into
`500 storage_failed` before anything is sent, and a submission that reached
neither the mail nor the configured Telegram channel into `500 send_failed`.
Delivery itself is dual-channel: the mail is the record and the Telegram
notification the bell, so the submission counts as delivered when either
gets through. The honeypot turns a bot into an indistinguishable success,
and a newsletter form swaps the send for the double opt-in: a pending entry
keyed by the SHA-256 hash of a single-use token, a mailed confirmation
link, and the owner notification only after the link is redeemed.
The client identity for rate limiting and logging is the connection peer
address unless `server.trust_proxy_headers` opts in to `X-Forwarded-For`
first hop, then `X-Real-IP`; one `ClientIP` implementation serves both the
limiter and the request log.
## State and lifetime
- Long-lived: the `ContactHandler`, one `rateLimiter` per form with its
cleanup goroutine, one pending store and one subscriber store per
newsletter form, one archive store per archiving form, and the metrics
registry. The binary holds no other state and opens no persistent
connections; every mail and every Telegram call dials fresh.
- The rate limiter is a per-IP token bucket refilling at
`perHour/3600` tokens per second, capped at
`server.rate_limit_max_buckets` buckets per form, cleaned every
`server.rate_limit_cleanup_seconds` of buckets idle longer than
`server.rate_limit_max_bucket_age_seconds`. On shutdown the buckets
snapshot to `data_dir/ratelimit-snapshot.json` (atomic write, mode
`0600`) and startup restores them, dropping stale entries.
- The JSONL stores append and close on every write; the subscriber log is
the record of confirmed addresses, the pending file holds hashed tokens
that expire with `pending_ttl_seconds`, and the archive holds full
submissions written before the send. There is no rotation: a `logrotate`
unit is the operator's move when a file grows.
- The metrics counters are lifetime values in memory and reset on restart;
the same trade-off as the rate-limit buckets.
- A single mutex guards each store and the limiter map, and the registry
snapshots under lock; all request-path state is safe for concurrent use.
## Dependencies
The one non-stdlib module is
[`interpres`](https://sourcedock.dev/petrbalvin/interpres), the first-party
TOML parser, chosen for the strict decoding that turns configuration typos
into startup failures. Everything else is the standard library: `net/http`
with its method-and-pattern `ServeMux`, `net/smtp` with `crypto/tls`,
`net/mail`, `log/slog`, `crypto/rand`, and `runtime/debug` for the recorded
version. The supply chain outside the forge is empty, which keeps the build
reproducible and the binary small.
+62
View File
@@ -0,0 +1,62 @@
# Command line
The reference below is taken from the program's own `--help`. If the two
disagree, the program is right and this file is a defect.
## Synopsis
```sh
nuntius [--version] [--check-config]
```
With no flags, nuntius loads the configuration, serves the form endpoints,
and shuts down gracefully on SIGINT or SIGTERM.
## Global flags
| Flag | Effect |
|---|---|
| `--help`, `-h` | prints the usage |
| `--version` | prints the release and exits: the tag at a tag, a pseudo-version naming the commit otherwise, `(devel)` outside version control, `+dirty` appended on a dirty tree |
| `--check-config` | loads and validates the configuration, then exits without listening; prints `configuration OK: <path>` on success, names the problem and exits nonzero on any error, so it runs as a systemd `ExecStartPre` |
Every flag is long-only, and Go's `flag` package accepts it with one or two
leading hyphens, so `-version` and `--version` are the same flag.
## Environment and files
`NUNTIUS_CONFIG` moves the configuration file away from
`/etc/nuntius/config.toml`. The full schema lives in
[`CONFIGURATION.md`](CONFIGURATION.md).
## Exit codes
| Code | Meaning |
|---|---|
| `0` | the version was printed, the configuration is valid, or the server shut down cleanly |
| `1` | the configuration is missing, invalid or fails validation, or the server failed to serve |
## Examples
Print the release:
```sh
nuntius --version
# nuntius v1.0.0
```
Validate a configuration without listening, as a deploy pipeline would:
```sh
NUNTIUS_CONFIG=./config.toml nuntius --check-config
# configuration OK: ./config.toml
```
## Manual page
A roff copy of this reference ships as `man/nuntius.1` in the repository
and installs with the binary; read it with:
```sh
man ./man/nuntius.1
```
+214
View File
@@ -0,0 +1,214 @@
# Configuration
nuntius reads its configuration from one TOML file, `/etc/nuntius/config.toml`
by default. The `NUNTIUS_CONFIG` environment variable points the server at
any other path. When the file does not exist, the first start writes a
three-form starter template there.
## File
A complete example with every key present at its default value, so the file
documents itself:
```toml
data_dir = "./data"
[server]
port = 8080
bind = "::"
read_header_timeout_seconds = 10
read_timeout_seconds = 15
write_timeout_seconds = 30
idle_timeout_seconds = 60
shutdown_timeout_seconds = 15
max_body_bytes = 1048576
rate_limit_max_buckets = 32768
rate_limit_cleanup_seconds = 3600
rate_limit_max_bucket_age_seconds = 7200
trust_proxy_headers = false
#metrics_token = "${NUNTIUS_METRICS_TOKEN}"
[[forms]]
name = "contact"
path = "/api/nuntius/contact"
type = "contact"
redirect_url = ""
archive = false
auto_reply = false
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
services = ["architecture", "ai", "infrastructure", "software", "unix", "other"]
require_name = true
require_message = true
min_name_runes = 2
max_name_runes = 100
min_message_runes = 10
max_message_runes = 5000
subject_prefix = "nuntius"
email_brand = "nuntius"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
require_tls = false
timeout_seconds = 20
[forms.telegram]
bot_token = "${NUNTIUS_TELEGRAM_TOKEN}"
chat_id = "123456789"
timeout_seconds = 10
[[forms]]
name = "newsletter"
path = "/api/nuntius/newsletter"
type = "newsletter"
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 100
honeypot_field = "bot_email"
allowed_origins = ["https://example.com"]
pending_ttl_seconds = 259200
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
```
The newsletter form shows the keys that apply to it; `archive`,
`auto_reply` and `telegram` do not apply to newsletter forms and are
rejected at startup.
## Keys
| Key | Type | Default | Effect |
|---|---|---|---|
| `data_dir` | string | `"./data"` | Directory for the JSONL logs; created on first write. Relative to the working directory. |
| `server.port` | int | `8080` | HTTP listen port. Must be 1 to 65535. |
| `server.bind` | string | `"::"` | Host part of the listen address; `"::"` is the dual-stack wildcard, an address binds that one only. Must not contain whitespace. |
| `server.read_header_timeout_seconds` | int | `10` | Budget for reading the request headers (Slowloris defence). `0` switches it off. |
| `server.read_timeout_seconds` | int | `15` | Budget for reading the request body. `0` switches it off. |
| `server.write_timeout_seconds` | int | `30` | Budget for writing the response. `0` switches it off. |
| `server.idle_timeout_seconds` | int | `60` | Keep-alive idle budget. `0` switches it off. |
| `server.shutdown_timeout_seconds` | int | `15` | Graceful shutdown budget before in-flight connections are cut. |
| `server.max_body_bytes` | int | `1048576` | Request body cap in bytes; oversized bodies get `413`. Must be at least 1. |
| `server.rate_limit_max_buckets` | int | `32768` | Per-form cap on distinct IP buckets; once full, unknown IPs are denied until cleanup frees stale entries. Must be at least 1. |
| `server.rate_limit_cleanup_seconds` | int | `3600` | Interval between bucket cleanup sweeps. Must be at least 1. |
| `server.rate_limit_max_bucket_age_seconds` | int | `7200` | Age at which an idle bucket is dropped. Must be at least 1. |
| `server.trust_proxy_headers` | bool | `false` | Read client IPs from `X-Forwarded-For` / `X-Real-IP` instead of the connection peer address. Enable only behind a proxy that overwrites these headers (Caddy 2.5+ does by default). |
| `server.metrics_token` | string | none | Bearer token guarding `GET /metrics`. Empty keeps the endpoint open. Supports `${VAR}` expansion. |
| `forms[].name` | string | yes | Internal identifier; appears in logs and file names. Letters, digits, hyphens and underscores only. |
| `forms[].path` | string | yes | HTTP path the form is served on. Must start with `/`, be unique, and be a static pattern with no empty segments. |
| `forms[].type` | string | `"contact"` | One of `contact`, `feedback`, `newsletter`, `generic`; selects the behaviour bundle, the mail template and the default validation policy. |
| `forms[].smtp` | table | yes | SMTP connection settings: `host`, `port`, `user`, `password`, `require_tls`, `timeout_seconds` (default `20`). Port `465` speaks implicit TLS; `587` upgrades via STARTTLS, which `require_tls = true` makes mandatory. |
| `forms[].to` | string | yes | Recipient address (`To:` header). |
| `forms[].from` | string | `smtp.user` | Sender address (`From:` header). |
| `forms[].redirect_url` | string | none | Turns the form into a plain HTML form target: accepted submissions answer `303 See Other` with this location. Failures stay JSON. Must not contain whitespace. |
| `forms[].archive` | bool | `false` | Persist every accepted submission to `data_dir/archive-<name>.jsonl` before the mail is attempted. Not valid on `newsletter` forms. |
| `forms[].auto_reply` | bool | `false` | Mail the submitter a short automated receipt with `Reply-To` set to `to`. Best effort; failures land in the `auto_reply_failed` counter. Not valid on `newsletter` forms. |
| `forms[].telegram` | table | none | Telegram notification channel: `bot_token` (supports `${VAR}`), `chat_id` (numeric id or `@channelusername`), `timeout_seconds` (default `10`). Not valid on `newsletter` forms. |
| `forms[].rate_limit_per_hour` | int | `10` | Submissions per IP per hour. `0` disables. |
| `forms[].honeypot_field` | string | `"website"` | Name of the invisible form field. Empty string disables the honeypot. |
| `forms[].allowed_origins` | []string | `[]` | CORS allowlist, one origin per entry; a same-origin HTML form lists its own origin too. |
| `forms[].services` | []string | type default | Allow-list for the optional `service` payload field, on any form type. |
| `forms[].require_name` | bool | type default | Switch the name field into the validation. |
| `forms[].require_message` | bool | type default | Switch the message field into the validation. |
| `forms[].min_name_runes` | int | `2` | Minimum name length in runes; 0 or more. |
| `forms[].max_name_runes` | int | `100` | Maximum name length in runes; at least `min_name_runes` and 1 or more. |
| `forms[].min_message_runes` | int | `10` | Minimum message length in runes; 0 or more. |
| `forms[].max_message_runes` | int | `5000` | Maximum message length in runes; at least `min_message_runes` and 1 or more. |
| `forms[].pending_ttl_seconds` | int | `259200` | Double opt-in lifetime for newsletter forms (72 hours). Must be at least 1. |
| `forms[].subject_prefix` | string | `"nuntius"` | The `[<prefix>/<name>]` segment of every mail subject. Empty string drops the segment. |
| `forms[].email_brand` | string | `"nuntius"` | The `Delivered by <brand>` footer in every mail. Empty string drops the footer. |
The email address is always required and always checked against RFC 5322:
every form delivers mail and needs a reply-to address, so there is no key
to switch that check off.
### Form types
The four types are behaviour bundles. Each bundles a mail template, a
subject text, and a default validation policy; every part of that policy
can be overridden per form with the keys above, so a form whose needs
differ is a configuration matter, never a code change.
| Type | Default required 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 checked against the allow-list: the built-in list by default, or the form's own `services` key. |
| `feedback` | `name`, `email`, `message` | `[nuntius/<name>] New feedback` | Same shape as `contact` minus the `service` field. Distinct HTML template. A `services` key opts the field into the validation and the mail. |
| `newsletter` | `email` | `[nuntius/<name>] New newsletter subscriber` | Double opt-in: the address waits in `data_dir/newsletter-<name>-pending.json` until the emailed link (`GET <form path>/confirm?token=...`) is redeemed. |
| `generic` | `name`, `email`, `message` | `[nuntius/<name>] New submission` | Escape hatch for one-off forms; pair it with the per-form policy keys to shape it freely. |
### The service allow-list
The `service` payload field is optional everywhere and the empty value
always passes, so a frontend that never sends it needs no configuration at
all. When a value is sent, it is checked against the form's allow-list:
| Configuration | Accepted service values |
|---|---|
| key omitted | `contact`: the built-in list below. Other types: the field is not validated at all. |
| `services = ["consulting", "support"]` | only the listed values (plus the empty value), on any form type |
| `services = ["*"]` | any value |
| `services = []` | only the empty value |
The built-in list for `contact`: `architecture`, `ai`, `infrastructure`,
`software`, `unix`, `other`. Anything else triggers a `400 validation`
error. Entries must not be empty and must not carry surrounding
whitespace; such a config is rejected at startup.
### The newsletter log
A confirmed signup is one JSON line in
`data_dir/newsletter-<name>.jsonl`:
```json
{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"}
```
`email` is the trimmed address, `ip` the client IP as seen by nuntius, and
`created_at` a UTC timestamp set at append time. The log is append-only,
survives restarts, and is never rotated automatically: add a `logrotate`
unit if it grows. Malformed lines are skipped on read, so a partial write
can never brick the file.
## Precedence
The configuration is one file plus the environment, in this order:
1. `NUNTIUS_CONFIG` chooses the file; without it the path is
`/etc/nuntius/config.toml`.
2. `${VAR_NAME}` and `$VAR_NAME` references in the raw text expand from the
process environment before parsing. Expansion is strict: a referenced
variable that is not set aborts startup with an error naming it, while a
set-but-empty value expands to the empty string and a dollar sign with no
variable name stays literal. Comment lines are never expanded.
3. Every key omitted from the file falls back to the default in the table
above; an explicit value, including a disabling zero, always wins.
Secrets belong in the environment, not the file: reference them as
`${NUNTIUS_SMTP_PASSWORD}` and provide them through the systemd
`EnvironmentFile=` (`/etc/nuntius/.env`, mode `0600`, which
`scripts/install.pl` sets up). Credentials are never logged.
## Validation
A wrong value stops the program at startup with a message naming the key;
nothing is silently ignored and nothing falls back silently. The loader
rejects unknown TOML fields and unknown form types, ports outside 1 to
65535, negative timeouts and limits, form paths that are not static route
patterns, form names with characters outside `[a-zA-Z0-9_-]`, policy
windows where the maximum sits below the minimum, service entries that are
empty or carry surrounding whitespace, `redirect_url` with whitespace,
`archive`, `auto_reply` or `telegram` on newsletter forms, and a Telegram
channel without `bot_token` or `chat_id`. At request time the payload is
validated against the form's policy: limits are counted in runes after
whitespace is trimmed, the email address must parse as RFC 5322, and the
error messages quote the configured numbers. See [`API.md`](API.md) for the
response shapes.
+181
View File
@@ -0,0 +1,181 @@
# Deployment
How nuntius runs in production: a single static binary behind Caddy, under
systemd, with its state in one data directory.
## Topology
```mermaid
flowchart LR
Browser[Visitor's browser] --> Caddy[Caddy :443]
Caddy -->|reverse_proxy /api/nuntius/*| Nuntius[nuntius :8080]
Systemd[Systemd unit] -.->|manages| Nuntius
Nuntius -->|SMTP PLAIN| SMTP[(SMTP provider)]
Nuntius -->|append JSONL| Disk[(/var/lib/nuntius/data)]
```
nuntius binds `[::]:8080` by default: the dual-stack wildcard that accepts
both IPv4 and IPv6 connections. The host part is the `server.bind` key in
`config.toml`; set it to `127.0.0.1` for a loopback-only listener, which is
the recommended shape behind a proxy.
### Reverse proxy
Add this directive to your Caddyfile (most likely `/etc/caddy/Caddyfile`):
```caddyfile
handle_path /api/nuntius/* {
reverse_proxy 127.0.0.1:8080
}
```
Then validate and reload:
```sh
sudo caddy validate
sudo systemctl reload caddy
```
Caddy overwrites `X-Forwarded-For` for untrusted clients, which is what the
`server.trust_proxy_headers` opt-in expects. Frontends on the same site
post to the same origin and need no extra URL; the per-form
`allowed_origins` list carries every origin that may post.
## Requirements
- A Linux or FreeBSD host with systemd and a C compiler-free runtime: the
binary is static, nothing else ships with it.
- The `nuntius` system user and group, the data directory
`/var/lib/nuntius`, the configuration at `/etc/nuntius/config.toml`, and
the environment file `/etc/nuntius/.env` (all created by the installer or
the manual steps below).
- Port 8080 locally, 443 publicly through Caddy.
- An SMTP account (any standards-compliant provider, such as Proton Mail or
Thundermail).
| File | Owner | Mode | Purpose |
|---|---|---|---|
| `/usr/local/bin/nuntius` | `root` | `0755` | The static binary |
| `/var/lib/nuntius/` | `nuntius` | `0750` | Working directory and data root |
| `/var/lib/nuntius/data/` | `nuntius` | `0750` | JSONL logs and the rate-limit snapshot (created at runtime) |
| `/etc/nuntius/config.toml` | `root` | `0644` | The configuration (auto-generated, then edited) |
| `/etc/nuntius/.env` | `root:nuntius` | `0600` | Secrets, loaded via `EnvironmentFile=` |
| `/etc/systemd/system/nuntius.service` | `root` | `0644` | The systemd unit |
## Build
```sh
just build
```
Copy the artefacts to the server:
```sh
rsync -avz bin/nuntius nuntius.service .env.example scripts/install.pl user@your-server:/tmp/nuntius/
```
## Run
The installer does the whole sequence and is idempotent; re-running is
safe and it never starts the service, so you review the configuration
first:
```sh
ssh user@your-server
cd /tmp/nuntius
sudo perl install.pl
```
It creates the `nuntius` system user, `/var/lib/nuntius`, installs the
binary and the unit, generates the starter configuration, seeds
`/etc/nuntius/.env` from `.env.example`, and enables the service. Then
review and start:
```sh
sudo $EDITOR /etc/nuntius/.env # set NUNTIUS_SMTP_PASSWORD
sudo $EDITOR /etc/nuntius/config.toml # real SMTP settings and allowed origins
sudo systemctl start nuntius
sudo journalctl -u nuntius -f
```
Without the installer, the same steps by hand: create the user and
directory as above, `install -m 0755` the binary, `install -m 0644` the
unit, start the binary once to generate `/etc/nuntius/config.toml`,
`systemctl daemon-reload && systemctl enable --now nuntius`.
## Service unit
The unit lives at `nuntius.service` in the repository root and installs to
`/etc/systemd/system/nuntius.service`; the copy in this document would
drift, the pointer does not. It runs as the `nuntius` user with
`WorkingDirectory=/var/lib/nuntius` (so the default `data_dir = "./data"`
resolves to `/var/lib/nuntius/data`), loads `/etc/nuntius/.env`, validates
the configuration through `ExecStartPre=/usr/local/bin/nuntius
--check-config`, and restarts on failure. The service is enabled, not
started, after installation.
## Production configuration
The keys that differ from the defaults in production: `allowed_origins`
carries the real frontend origins, the `[forms.smtp]` block carries the
real host and credentials, `server.metrics_token` guards the metrics
endpoint when it is exposed, and `server.bind` stays `::` or moves to a
loopback address behind the proxy. Secrets live in `/etc/nuntius/.env` and
reach the configuration through `${VAR}` references; no secret value
belongs in `config.toml` or in this repository.
## Upgrade
```sh
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'
```
The service does not reload `config.toml`; after editing it, restart. A
broken configuration exits 1 and systemd does not keep it up, so fix the
reported key and start again. `just gates` before the new binary ships.
## Rollback
Reinstall the previous release's binary from the releases page and
restart; the configuration, the JSONL logs and the rate-limit snapshot
carry over untouched. Rehearsed exactly this far, and no further: there is
no automated rollback path. The reverse of the whole installation is
`systemctl disable --now nuntius`, removing the unit, the binary,
`/etc/nuntius` and `/var/lib/nuntius`, and deleting the user.
## Monitoring
A healthy instance answers the health endpoint and writes one JSON request
line per submission:
```sh
systemctl status nuntius
/usr/local/bin/nuntius --version
curl -s http://127.0.0.1:8080/health
journalctl -u nuntius -n 50 --no-pager
curl -i https://your-domain.example/api/nuntius/health
```
An end-to-end check through the proxy:
```sh
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"}'
```
`{"ok": true}` plus a `request` line with `status=200` in the journal means
the deploy is live. Subscriber counts come from the log:
```sh
wc -l /var/lib/nuntius/data/newsletter-newsletter.jsonl
jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort -u
```
Back up `/etc/nuntius/config.toml`, `/etc/nuntius/.env` and
`/var/lib/nuntius/data/*.jsonl` with the usual jobs; the log is
append-only and trivially archivable.
+130
View File
@@ -0,0 +1,130 @@
# Development
How to work on nuntius.
## Prerequisites
- Go 1.27.1, the version recorded in `go.mod`, the newest stable release.
- [just](https://github.com/casey/just) for the recipes.
- gcc for `just gates`: the race detector needs cgo.
Nothing else. There is no Node, no Python, and no third-party Go module
beyond the first-party [`interpres`](https://sourcedock.dev/petrbalvin/interpres)
TOML parser.
## Setup
```sh
git clone https://sourcedock.dev/petrbalvin/nuntius.git
cd nuntius
just build
```
Configuration lives in TOML, read from `/etc/nuntius/config.toml` by
default; the `NUNTIUS_CONFIG` environment variable points the server at any
file you like, which keeps development free of root-owned paths:
```sh
export NUNTIUS_CONFIG=./config.toml
export NUNTIUS_SMTP_PASSWORD=admin # the placeholder the template references
just run # first start writes the starter config
```
The starter forms point at example.com addresses, so submissions return
`500 send_failed` until `config.toml` carries real SMTP settings; strict
environment expansion means every `${VAR}` the config references must be set
before startup.
## Recipes
Every recipe in the project's file, taken from the file itself:
| Recipe | What it does |
|--------|--------------|
| `just default` | prints the recipe list |
| `just build` | `CGO_ENABLED=0 go build -trimpath -buildvcs=true -ldflags "-s -w"` into `bin/nuntius` |
| `just test` | the suite with no test cache, then the 80 % coverage floor over `./internal/...` |
| `just race` | the same suite under the race detector |
| `just unit` | fast scoped run for iterating: cached, no race, no coverage |
| `just fuzz` | time-boxed fuzz of one target in one package |
| `just bench` | benchmarks |
| `just fmt` | `gofmt -w .` |
| `just fmt-check` | zero gofmt diff; prints nothing when everything is formatted |
| `just vet` | `go vet ./...` and `go fix -diff ./...` |
| `just gates` | build, fmt-check, vet, test, race: the definition of done, once per task |
| `just clean` | removes `bin/` and `coverage.out` |
| `just install` | builds, then copies the binary into `~/.local/bin` (override with `BINDIR`) |
| `just uninstall` | removes the installed binary |
| `just run` | `go run -buildvcs=true ./cmd/server` |
| `just dev` | the same as `run`: nuntius carries no watch or reload tool |
| `just coverage-html` | HTML coverage report from the gate's profile; an extension, not a gate |
The test, race, unit and fuzz recipes run under a cgroup memory fence, so a
runaway test dies at the ceiling instead of eating the machine.
## Running a single test
```sh
go test -run TestName ./internal/handler/
```
Add `-v` for the sub-test names, and `-race` when the change touches
concurrency. `-count=1` defeats the test cache when a result looks stale;
`just unit` keeps the cache on purpose, because a scoped iterating run wants
to be instant.
## Coverage
```sh
just test
go tool cover -func=coverage.out
```
The `total:` line is the number that matters, and it stays at 80 percent or
more. Coverage is measured over the logic packages only; `cmd/server` is
thin glue around them. For the HTML map:
```sh
just coverage-html
```
## Benchmarks
```sh
just bench
```
Benchmark on an idle machine, and compare only runs made in one process
against each other: runs in separate processes, or on a loaded machine,
differ by more than the effects being measured.
## Fuzzing
Two fuzz targets exist: `FuzzValidate` in `internal/contactform` and
`FuzzLoadConfig` in `internal/config`. They are exploration, never a gate;
time-box one explicitly:
```sh
just fuzz FuzzValidate ./internal/contactform 30s
```
## Debugging the build
```sh
go build -gcflags='-m' ./... # inlining decisions
go build -gcflags='-S' ./... # what the compiler generated
```
## Continuous integration
Workflows live in `.gitea/workflows/` and run on the project's own runners.
They are written by hand rather than through `just`, but they enforce the
same set of gates, so a green `just gates` locally is the fastest way to a
green pipeline. The per-workflow table is in
[CONTRIBUTING.md](../CONTRIBUTING.md).
## Releases
Releases are cut by merging `development` into `main` and tagging `vX.Y.Z`.
The tag drives the release workflow, which builds the assets and publishes
the notes it extracted from `CHANGELOG.md`.
+5
View File
@@ -0,0 +1,5 @@
module sourcedock.dev/petrbalvin/nuntius
go 1.27.1
require sourcedock.dev/petrbalvin/interpres/v2 v2.0.0
+2
View File
@@ -0,0 +1,2 @@
sourcedock.dev/petrbalvin/interpres/v2 v2.0.0 h1:DkWtszKv4BTafedilEKY5QuTaOSv/J8gpcxHoi6oHnw=
sourcedock.dev/petrbalvin/interpres/v2 v2.0.0/go.mod h1:SCMhffAzwoPrmeHKHeAar7dKm58QKKMdL8qhaZmc4ds=
+850
View File
@@ -0,0 +1,850 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//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. It 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"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
// Default values applied by validate() when fields are omitted.
const (
DefaultPort = 8080
DefaultDataDir = "./data"
DefaultRateLimitPerHour = 10
DefaultHoneypotField = "website"
// Bind is the host part of the listen address: the dual-stack IPv6
// wildcard, rendered as "[::]:port" once the port is joined on.
DefaultBind = "::"
// HTTP server timeouts, in seconds. An explicit 0 keeps its net/http
// meaning: the timeout is switched off.
DefaultReadHeaderTimeoutSeconds = 10
DefaultReadTimeoutSeconds = 15
DefaultWriteTimeoutSeconds = 30
DefaultIdleTimeoutSeconds = 60
DefaultShutdownTimeoutSeconds = 15
// Request body cap and the rate limiter's memory mechanics.
DefaultMaxBodyBytes = 1 << 20
DefaultRateLimitMaxBuckets = 32768
DefaultRateLimitCleanupSeconds = 3600
DefaultRateLimitMaxBucketAgeSeconds = 7200
// Newsletter double opt-in lifetime: 72 hours.
DefaultPendingTTLSeconds = 72 * 3600
// Telegram API call bound, in seconds.
DefaultTelegramTimeoutSeconds = 10
// SMTP conversation bound, in seconds.
DefaultSMTPTimeoutSeconds = 20
// Mail branding: the subject prefix and the "Delivered by" footer.
DefaultSubjectPrefix = "nuntius"
DefaultEmailBrand = "nuntius"
)
// validFormTypes lists the form types nuntius knows how to handle.
var validFormTypes = map[string]bool{
"contact": true,
"feedback": true,
"newsletter": true,
"generic": true,
}
// IsValidFormType reports whether t is a supported form type.
func IsValidFormType(t string) bool {
return validFormTypes[t]
}
// 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"`
// Bind is the host part of the listen address, resolved to DefaultBind
// by validate() when omitted. "::" binds the dual-stack wildcard, an
// address or hostname binds that one only.
Bind string `toml:"bind"`
// TrustProxyHeaders opts in to client IPs read from X-Forwarded-For /
// X-Real-IP instead of the connection peer address. It is off by
// default because those headers are client-controlled: without a
// trusted reverse proxy in front of nuntius that overwrites them,
// enabling this would let any caller forge its rate-limit identity.
TrustProxyHeaders bool `toml:"trust_proxy_headers"`
// MetricsToken guards GET /metrics with a bearer token. Empty (the
// default) keeps the endpoint open, protected at the reverse proxy
// like any other operational surface. The value supports the same
// ${VAR} expansion as the rest of the file.
MetricsToken string `toml:"metrics_token"`
// The timeout fields are pointers so that an omitted key falls back to
// its default while an explicit 0 keeps its net/http meaning: the
// timeout is switched off. The getters return durations.
ReadHeaderTimeoutSeconds *int `toml:"read_header_timeout_seconds"`
ReadTimeoutSeconds *int `toml:"read_timeout_seconds"`
WriteTimeoutSeconds *int `toml:"write_timeout_seconds"`
IdleTimeoutSeconds *int `toml:"idle_timeout_seconds"`
ShutdownTimeoutSeconds *int `toml:"shutdown_timeout_seconds"`
// MaxBodyBytes caps the JSON request body. An explicit value must be
// at least 1 byte.
MaxBodyBytes *int `toml:"max_body_bytes"`
// Rate limiter mechanics: the per-form cap on distinct IP buckets, the
// cleanup tick and the age at which an idle bucket is dropped (also
// applied when a restart restores the persisted buckets). Explicit
// values must be at least 1.
RateLimitMaxBuckets *int `toml:"rate_limit_max_buckets"`
RateLimitCleanupSeconds *int `toml:"rate_limit_cleanup_seconds"`
RateLimitMaxBucketAgeSeconds *int `toml:"rate_limit_max_bucket_age_seconds"`
}
// ReadHeaderTimeout returns the budget for reading the request headers.
func (s ServerConfig) ReadHeaderTimeout() time.Duration {
return secondsOrDefault(s.ReadHeaderTimeoutSeconds, DefaultReadHeaderTimeoutSeconds)
}
// ReadTimeout returns the budget for reading the request body.
func (s ServerConfig) ReadTimeout() time.Duration {
return secondsOrDefault(s.ReadTimeoutSeconds, DefaultReadTimeoutSeconds)
}
// WriteTimeout returns the budget for writing the response.
func (s ServerConfig) WriteTimeout() time.Duration {
return secondsOrDefault(s.WriteTimeoutSeconds, DefaultWriteTimeoutSeconds)
}
// IdleTimeout returns the keep-alive idle budget.
func (s ServerConfig) IdleTimeout() time.Duration {
return secondsOrDefault(s.IdleTimeoutSeconds, DefaultIdleTimeoutSeconds)
}
// ShutdownTimeout returns the graceful shutdown budget.
func (s ServerConfig) ShutdownTimeout() time.Duration {
return secondsOrDefault(s.ShutdownTimeoutSeconds, DefaultShutdownTimeoutSeconds)
}
// BodyLimit returns the request body cap in bytes.
func (s ServerConfig) BodyLimit() int {
if s.MaxBodyBytes == nil {
return DefaultMaxBodyBytes
}
return *s.MaxBodyBytes
}
// MaxRateLimitBuckets returns the per-form cap on distinct IP buckets.
func (s ServerConfig) MaxRateLimitBuckets() int {
if s.RateLimitMaxBuckets == nil {
return DefaultRateLimitMaxBuckets
}
return *s.RateLimitMaxBuckets
}
// RateLimitCleanup returns the interval between bucket cleanup sweeps.
func (s ServerConfig) RateLimitCleanup() time.Duration {
return secondsOrDefault(s.RateLimitCleanupSeconds, DefaultRateLimitCleanupSeconds)
}
// RateLimitMaxBucketAge returns the age at which an idle bucket is dropped.
func (s ServerConfig) RateLimitMaxBucketAge() time.Duration {
return secondsOrDefault(s.RateLimitMaxBucketAgeSeconds, DefaultRateLimitMaxBucketAgeSeconds)
}
// secondsOrDefault converts an optional seconds value into a duration: a
// nil pointer yields the default, an explicit 0 stays 0 (the documented
// "switched off" meaning), anything else is the value in seconds.
func secondsOrDefault(v *int, def int) time.Duration {
if v == nil {
return time.Duration(def) * time.Second
}
return time.Duration(*v) * time.Second
}
// Form is one contact / feedback / newsletter endpoint.
//
// Optional numeric and string options are pointers so that an omitted key
// can fall back to its default while an explicit zero value keeps its
// documented meaning (`rate_limit_per_hour = 0` disables the limit,
// `honeypot_field = ""` disables the honeypot).
//
// The validation keys (services, require_name, require_message and the
// four rune limits) override the built-in preset of the form's type, so a
// new form shape is a matter of configuration, never of Go code.
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"`
// RedirectURL turns the form into a plain HTML form target: an
// accepted submission answers 303 See Other with this location, so
// the form works without JavaScript. Empty (the default) keeps the
// JSON contract.
RedirectURL string `toml:"redirect_url"`
// Archive persists every accepted submission to
// data_dir/archive-<name>.jsonl before the mail is attempted, so a
// failed SMTP round-trip loses nothing. Newsletter forms always
// persist through the double opt-in log instead and reject the key.
Archive bool `toml:"archive"`
// AutoReply mails the submitter a short receipt confirming the
// message arrived. The submitter's address is always validated
// first and the request is rate limited like any other, so the
// receipt cannot be turned into a mail relay. Newsletter forms
// reject the key: their subscribers already receive the
// confirmation mail.
AutoReply bool `toml:"auto_reply"`
// Telegram is the optional notification channel: the submission
// summary lands in the chat alongside the mail. The submission
// counts as delivered when either channel gets through. Newsletter
// forms reject the key: the double opt-in flow is mail-native.
Telegram *TelegramConfig `toml:"telegram"`
// Services is the allow-list for the optional service payload field.
// A nil value (the key omitted) keeps the type's preset behaviour: the
// built-in list for contact, no service validation for the other
// types. An explicit list applies to any type; the empty value always
// passes and the "*" entry accepts any value. An explicit empty list
// accepts only the empty value.
Services []string `toml:"services"`
// RequireName and RequireMessage switch the two free-text fields into
// the validation. The email address is always required.
RequireName *bool `toml:"require_name"`
RequireMessage *bool `toml:"require_message"`
// Length limits in runes; the preset values are 2 and 100 for the name
// and 10 and 5000 for the message.
MinNameRunes *int `toml:"min_name_runes"`
MaxNameRunes *int `toml:"max_name_runes"`
MinMessageRunes *int `toml:"min_message_runes"`
MaxMessageRunes *int `toml:"max_message_runes"`
// PendingTTLSeconds is the double opt-in lifetime for newsletter
// forms; the default is 72 hours.
PendingTTLSeconds *int `toml:"pending_ttl_seconds"`
// SubjectPrefix carries the "[nuntius/<name>]" segment of every mail
// subject; an explicit empty string drops the segment.
SubjectPrefix *string `toml:"subject_prefix"`
// EmailBrand carries the "Delivered by <brand>" footer; an explicit
// empty string drops the footer.
EmailBrand *string `toml:"email_brand"`
}
// RateLimit returns the effective submissions-per-hour cap for the form.
// An omitted value yields DefaultRateLimitPerHour; an explicit 0 disables
// rate limiting. Validate rejects negative values at load time.
func (f *Form) RateLimit() int {
if f.RateLimitPerHour == nil {
return DefaultRateLimitPerHour
}
return *f.RateLimitPerHour
}
// Honeypot returns the name of the hidden anti-bot field. An omitted value
// yields DefaultHoneypotField; an explicit empty string disables the
// honeypot for the form.
func (f *Form) Honeypot() string {
if f.HoneypotField == nil {
return DefaultHoneypotField
}
return *f.HoneypotField
}
// PendingTTL returns the double opt-in lifetime for newsletter forms.
func (f *Form) PendingTTL() time.Duration {
if f.PendingTTLSeconds == nil {
return time.Duration(DefaultPendingTTLSeconds) * time.Second
}
return time.Duration(*f.PendingTTLSeconds) * time.Second
}
// EmailSubjectPrefix returns the "[<prefix>/<form name>]" segment of every
// mail subject; an explicit empty string disables the segment.
func (f *Form) EmailSubjectPrefix() string {
if f.SubjectPrefix == nil {
return DefaultSubjectPrefix
}
return *f.SubjectPrefix
}
// Brand returns the "Delivered by <brand>" footer name; an explicit empty
// string disables the footer.
func (f *Form) Brand() string {
if f.EmailBrand == nil {
return DefaultEmailBrand
}
return *f.EmailBrand
}
// Policy builds the validation policy for this form: the built-in preset
// for its type with every configured key overriding the preset value.
func (f *Form) Policy() contactform.Policy {
p := contactform.Preset(f.Type)
if f.RequireName != nil {
p.RequireName = *f.RequireName
}
if f.RequireMessage != nil {
p.RequireMessage = *f.RequireMessage
}
if f.MinNameRunes != nil {
p.MinNameRunes = *f.MinNameRunes
}
if f.MaxNameRunes != nil {
p.MaxNameRunes = *f.MaxNameRunes
}
if f.MinMessageRunes != nil {
p.MinMessageRunes = *f.MinMessageRunes
}
if f.MaxMessageRunes != nil {
p.MaxMessageRunes = *f.MaxMessageRunes
}
if f.Services != nil {
p.Services = f.Services
}
return p
}
// SMTPConfig holds SMTP credentials and connection details.
type SMTPConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
User string `toml:"user"`
// Password is expanded from the environment before the TOML parse.
Password string `toml:"password"`
// RequireTLS aborts delivery when an SMTP server on a STARTTLS port
// never advertises STARTTLS. Irrelevant for implicit-TLS ports.
RequireTLS bool `toml:"require_tls"`
// TimeoutSeconds bounds the whole SMTP conversation. An explicit value
// must be at least 1.
TimeoutSeconds *int `toml:"timeout_seconds"`
}
// TelegramConfig holds the optional Telegram notification channel: one
// bot posting submission summaries into one chat.
type TelegramConfig struct {
// BotToken is the bot's token from BotFather, expanded from the
// environment like every other secret.
BotToken string `toml:"bot_token"`
// ChatID is the chat receiving the summaries: a numeric chat id
// (group chats carry a negative number) or an @channelusername.
ChatID string `toml:"chat_id"`
// TimeoutSeconds bounds the whole API call. An explicit value must
// be at least 1.
TimeoutSeconds *int `toml:"timeout_seconds"`
}
// Timeout returns the bound on the whole Telegram API call.
func (t TelegramConfig) Timeout() time.Duration {
if t.TimeoutSeconds == nil {
return time.Duration(DefaultTelegramTimeoutSeconds) * time.Second
}
return time.Duration(*t.TimeoutSeconds) * time.Second
}
// Timeout returns the bound on the whole SMTP conversation.
func (s SMTPConfig) Timeout() time.Duration {
if s.TimeoutSeconds == nil {
return time.Duration(DefaultSMTPTimeoutSeconds) * time.Second
}
return time.Duration(*s.TimeoutSeconds) * time.Second
}
// ImplicitTLSPort is the SMTP port that speaks TLS from the first byte.
// Port 465 upgrades the connection itself instead of negotiating STARTTLS
// inside a plaintext session.
const ImplicitTLSPort = 465
// 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)
}
// O_EXCL makes the create atomic: if two instances race, only one
// succeeds and the other sees os.ErrExist, which is safe to ignore.
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err == nil {
if _, werr := f.Write(defaultConfig); werr != nil {
f.Close()
return nil, fmt.Errorf("write default config to %s: %w", path, werr)
}
if cerr := f.Close(); cerr != nil {
return nil, fmt.Errorf("close default config %s: %w", path, cerr)
}
} else if !os.IsExist(err) {
return nil, fmt.Errorf("create default config %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, failing on
// variables that are referenced but not set.
expanded, err := expandConfig(string(raw))
if err != nil {
return nil, fmt.Errorf("expand env vars in %s: %w", path, err)
}
// Step 2: parse TOML, rejecting unknown fields to catch typos early.
var cfg Config
if err := interpres.Unmarshal([]byte(expanded), &cfg, interpres.RejectUnknownFields(true)); 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 = DefaultPort
}
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("server.port must be between 1 and 65535, got %d", c.Server.Port)
}
if c.Server.Bind == "" {
c.Server.Bind = DefaultBind
}
if strings.ContainsAny(c.Server.Bind, " \t\r\n") {
return fmt.Errorf("server.bind %q must not contain whitespace", c.Server.Bind)
}
for name, v := range map[string]*int{
"server.read_header_timeout_seconds": c.Server.ReadHeaderTimeoutSeconds,
"server.read_timeout_seconds": c.Server.ReadTimeoutSeconds,
"server.write_timeout_seconds": c.Server.WriteTimeoutSeconds,
"server.idle_timeout_seconds": c.Server.IdleTimeoutSeconds,
"server.shutdown_timeout_seconds": c.Server.ShutdownTimeoutSeconds,
} {
if v != nil && *v < 0 {
return fmt.Errorf("%s must be >= 0, got %d", name, *v)
}
}
for name, v := range map[string]*int{
"server.max_body_bytes": c.Server.MaxBodyBytes,
"server.rate_limit_max_buckets": c.Server.RateLimitMaxBuckets,
"server.rate_limit_cleanup_seconds": c.Server.RateLimitCleanupSeconds,
"server.rate_limit_max_bucket_age_seconds": c.Server.RateLimitMaxBucketAgeSeconds,
} {
if v != nil && *v < 1 {
return fmt.Errorf("%s must be >= 1, got %d", name, *v)
}
}
if c.DataDir == "" {
c.DataDir = DefaultDataDir
}
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 !isValidName(f.Name) {
return fmt.Errorf("form %q: name may only contain letters, digits, hyphens and underscores", f.Name)
}
if f.Path == "" {
return fmt.Errorf("form %q: path is required", f.Name)
}
if f.Type == "" {
c.Forms[i].Type = "contact"
}
if !IsValidFormType(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 !isValidPath(f.Path) {
return fmt.Errorf("form %q: path %q must start with / and contain only letters, digits, '-', '_', '.', '/' with no empty segments", f.Name, f.Path)
}
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.RedirectURL != "" && strings.ContainsAny(f.RedirectURL, " \t\r\n") {
return fmt.Errorf("form %q: redirect_url %q must not contain whitespace", f.Name, f.RedirectURL)
}
if f.Archive && f.Type == "newsletter" {
return fmt.Errorf("form %q: archive does not apply to newsletter forms; they persist through the double opt-in log", f.Name)
}
if f.AutoReply && f.Type == "newsletter" {
return fmt.Errorf("form %q: auto_reply does not apply to newsletter forms; the subscriber already receives the confirmation mail", f.Name)
}
if f.Telegram != nil {
if f.Type == "newsletter" {
return fmt.Errorf("form %q: telegram does not apply to newsletter forms; the double opt-in flow is mail-native", f.Name)
}
if f.Telegram.BotToken == "" {
return fmt.Errorf("form %q: telegram.bot_token is required", f.Name)
}
if f.Telegram.ChatID == "" {
return fmt.Errorf("form %q: telegram.chat_id is required", f.Name)
}
if f.Telegram.TimeoutSeconds != nil && *f.Telegram.TimeoutSeconds < 1 {
return fmt.Errorf("form %q: telegram.timeout_seconds must be >= 1, got %d", f.Name, *f.Telegram.TimeoutSeconds)
}
}
// An explicit 0 keeps its documented meaning: rate limiting off.
if f.RateLimitPerHour != nil && *f.RateLimitPerHour < 0 {
return fmt.Errorf("form %q: rate_limit_per_hour must be >= 0, got %d", f.Name, *f.RateLimitPerHour)
}
if f.PendingTTLSeconds != nil && *f.PendingTTLSeconds < 1 {
return fmt.Errorf("form %q: pending_ttl_seconds must be >= 1, got %d", f.Name, *f.PendingTTLSeconds)
}
if f.SMTP.TimeoutSeconds != nil && *f.SMTP.TimeoutSeconds < 1 {
return fmt.Errorf("form %q: smtp.timeout_seconds must be >= 1, got %d", f.Name, *f.SMTP.TimeoutSeconds)
}
for _, svc := range f.Services {
if svc == "" {
return fmt.Errorf("form %q: services must not contain an empty entry; the empty value is always accepted", f.Name)
}
if strings.TrimSpace(svc) != svc {
return fmt.Errorf("form %q: services entry %q must not carry surrounding whitespace", f.Name, svc)
}
}
if err := checkPolicyLimits(f.Name, f.Policy()); err != nil {
return err
}
}
return nil
}
// checkPolicyLimits rejects a form policy whose length limits are
// unusable: negative minima, a maximum below one, or a window that
// excludes everything.
func checkPolicyLimits(formName string, p contactform.Policy) error {
for _, l := range []struct {
field string
min int
max int
}{
{"name", p.MinNameRunes, p.MaxNameRunes},
{"message", p.MinMessageRunes, p.MaxMessageRunes},
} {
if l.min < 0 {
return fmt.Errorf("form %q: min_%s_runes must be >= 0, got %d", formName, l.field, l.min)
}
if l.max < 1 {
return fmt.Errorf("form %q: max_%s_runes must be >= 1, got %d", formName, l.field, l.max)
}
if l.max < l.min {
return fmt.Errorf("form %q: max_%s_runes (%d) must be greater than or equal to min_%s_runes (%d)",
formName, l.field, l.max, l.field, l.min)
}
}
return nil
}
// isValidName reports whether s contains only safe characters for use
// in file paths and log lines.
func isValidName(s string) bool {
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
}
// isValidPath reports whether s is a safe HTTP route pattern: a static path
// built from a leading slash plus letters, digits, hyphens, underscores,
// dots and single-slash separators. Characters that carry meaning inside
// net/http ServeMux patterns (braces, spaces, ...) are rejected so that a
// mistyped config cannot crash route registration or silently widen a form
// endpoint into a wildcard or subtree match.
func isValidPath(s string) bool {
if !strings.HasPrefix(s, "/") || strings.Contains(s, "//") {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z',
r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '/':
default:
return false
}
}
return true
}
// ConfigPath returns the canonical path for nuntius configuration:
// the NUNTIUS_CONFIG environment variable when set, otherwise
// /etc/nuntius/config.toml. The override keeps local development free of
// root-only paths.
func ConfigPath() string {
if p := os.Getenv("NUNTIUS_CONFIG"); p != "" {
return p
}
return "/etc/nuntius/config.toml"
}
// expandConfig applies expandEnv to every non-comment line of a TOML file.
// Lines whose first non-blank character is '#' are documentation, never
// values, so dollar signs there must survive verbatim even when they show
// placeholder syntax like ${VAR_NAME}.
func expandConfig(s string) (string, error) {
lines := strings.Split(s, "\n")
for i, ln := range lines {
if strings.HasPrefix(strings.TrimLeft(ln, " \t"), "#") {
continue
}
out, err := expandEnv(ln)
if err != nil {
return "", err
}
lines[i] = out
}
return strings.Join(lines, "\n"), nil
}
// expandEnv substitutes $VAR and ${VAR} references with their environment
// values. Syntax follows os.Expand. Unlike os.Expand it fails when a
// referenced variable is not set: a silently empty SMTP password is far
// harder to diagnose than an explicit startup error. A dollar sign not
// followed by a variable name stays literal.
func expandEnv(s string) (string, error) {
var b strings.Builder
for i := 0; i < len(s); {
c := s[i]
if c != '$' {
b.WriteByte(c)
i++
continue
}
name, width := varName(s[i+1:])
if name == "" {
// "$" with no name after it (or unterminated braces): keep it.
b.WriteByte('$')
i++
continue
}
value, ok := os.LookupEnv(name)
if !ok {
return "", fmt.Errorf("environment variable %s is referenced but not set", name)
}
b.WriteString(value)
i += 1 + width
}
return b.String(), nil
}
// varName parses the variable name that follows a dollar sign, mirroring
// os.Expand rules: either "${NAME}" or a bare run of ASCII letters, digits
// and underscores. The second result counts the bytes consumed after the
// dollar sign; zero width means there was no name at all.
func varName(s string) (string, int) {
if len(s) > 0 && s[0] == '{' {
end := strings.IndexByte(s, '}')
if end < 0 {
return "", 0
}
return s[1:end], end + 1
}
n := 0
for n < len(s) {
c := s[n]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_') {
break
}
n++
}
return s[:n], n
}
// 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. Referencing a
# variable that is not set aborts startup with an explicit error.
#
# Every key below is optional: omitting one keeps its default. The commented
# lines document the keys most deployments never need to touch.
data_dir = "./data"
[server]
port = 8080
# Host part of the listen address; the default "::" binds the dual-stack
# wildcard, so port 8080 answers on IPv4 and IPv6 alike.
#bind = "::"
# HTTP timeouts in seconds; 0 switches a timeout off. Defaults: 10, 15, 30, 60.
#read_header_timeout_seconds = 10
#read_timeout_seconds = 15
#write_timeout_seconds = 30
#idle_timeout_seconds = 60
# Graceful shutdown budget in seconds (default 15).
#shutdown_timeout_seconds = 15
# Request body cap in bytes (default 1048576, i.e. 1 MiB).
#max_body_bytes = 1048576
# Rate limiter mechanics: per-form cap on distinct IP buckets, cleanup tick
# and the age at which an idle bucket is dropped (defaults 32768, 3600, 7200).
#rate_limit_max_buckets = 32768
#rate_limit_cleanup_seconds = 3600
#rate_limit_max_bucket_age_seconds = 7200
# Bearer token guarding GET /metrics; empty keeps the endpoint open.
#metrics_token = "${NUNTIUS_METRICS_TOKEN}"
[[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"]
# Plain HTML form mode: an accepted submission answers 303 See Other with
# this location, so the form works without JavaScript. Omitted (the
# default), accepted submissions answer JSON.
#redirect_url = "https://example.com/thanks"
# Archive accepted submissions to data_dir/archive-<name>.jsonl before the
# mail goes out, so a failed SMTP round-trip loses nothing. Newsletter
# forms always persist through the double opt-in log instead.
#archive = true
# Send the submitter a short automated receipt. Newsletter forms reject
# the key; their subscribers already receive the confirmation mail.
#auto_reply = true
# Telegram notification channel: the summary lands in the chat alongside
# the mail, and the submission counts as delivered when either gets
# through. Newsletter forms reject the key.
#[forms.telegram]
#bot_token = "${NUNTIUS_TELEGRAM_TOKEN}"
#chat_id = "123456789"
#timeout_seconds = 10
# Allow-list for the optional "service" payload field. The empty value is
# always accepted; the "*" entry accepts any value; omitting the key keeps
# the built-in list below; an explicit empty list accepts only the empty
# value. The same key works on any form type.
services = ["architecture", "ai", "infrastructure", "software", "unix", "other"]
# Validation overrides on top of the type preset; the values shown are the
# defaults. The email address is always required and never length-limited.
#require_name = true
#require_message = true
#min_name_runes = 2
#max_name_runes = 100
#min_message_runes = 10
#max_message_runes = 5000
# Subject prefix, rendered as "[nuntius/contact]" in every mail subject; an
# empty string drops the bracket segment. Default "nuntius".
#subject_prefix = "nuntius"
# Footer brand, rendered as "Delivered by nuntius"; an empty string drops
# the footer. Default "nuntius".
#email_brand = "nuntius"
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
# Whole SMTP conversation bound in seconds (default 20).
#timeout_seconds = 20
[[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"]
# Double opt-in lifetime in seconds (default 259200, i.e. 72 hours).
#pending_ttl_seconds = 259200
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "contact@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
`)
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package contactform provides the types and the declarative validation
// the form pipeline runs on.
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"`
}
// 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"`
}
+172
View File
@@ -0,0 +1,172 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package contactform
import (
"fmt"
"net/mail"
"slices"
"strings"
"unicode/utf8"
)
// MinNameRunes is the default minimum allowed length of a name.
const MinNameRunes = 2
// MaxNameRunes is the default maximum allowed length of a name.
const MaxNameRunes = 100
// MinMessageRunes is the default minimum allowed length of a message body.
const MinMessageRunes = 10
// MaxMessageRunes is the default maximum allowed length of a message body.
const MaxMessageRunes = 5000
// ServiceAny is the services entry that accepts any service value.
const ServiceAny = "*"
// DefaultServices is the built-in service allow-list the contact preset
// applies when a form does not define its own list. The empty value is
// always accepted on top of whatever this list holds, because a form that
// offers no choice never sends the field at all.
var DefaultServices = []string{
"architecture",
"ai",
"infrastructure",
"software",
"unix",
"other",
}
// Policy is the declarative validation rule set for one form. The server
// builds it from the form's configuration; a library caller writes it
// directly. The zero value accepts any name and message, so every limit
// that matters must be set explicitly.
type Policy struct {
// RequireName and RequireMessage switch the length checks for the two
// free-text fields on and off. The email address is always required
// and always checked: every form delivers mail and needs a reply-to.
RequireName bool
RequireMessage bool
MinNameRunes int
MaxNameRunes int
MinMessageRunes int
MaxMessageRunes int
// Services is the allow-list for the optional service field. A nil
// list means the field is not validated at all; a non-nil list holds
// the accepted values, with the empty value always accepted and the
// ServiceAny entry lifting the restriction entirely.
Services []string
}
// Preset returns the built-in policy for a form type. An empty or unknown
// type falls back to the contact preset, which is also how the server
// treats an unconfigured type. Every preset carries the default length
// limits; the newsletter preset simply leaves both free-text fields out
// of the checks, so switching them on later starts from sane limits.
func Preset(formType string) Policy {
switch formType {
case "newsletter":
return Policy{
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
}
case "feedback", "generic":
return Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
}
default: // contact, and the fallback for every unknown type
return Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: MinNameRunes,
MaxNameRunes: MaxNameRunes,
MinMessageRunes: MinMessageRunes,
MaxMessageRunes: MaxMessageRunes,
Services: slices.Clone(DefaultServices),
}
}
}
// Validate normalises the request in place (trims whitespace from every
// text field) and checks it against p. It returns one entry per failed
// field; a nil slice means the request is valid.
func Validate(r *Request, p Policy) []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 p.RequireName {
errs = append(errs, checkRunes("name", r.Name, p.MinNameRunes, p.MaxNameRunes)...)
}
if _, err := mail.ParseAddress(r.Email); err != nil {
errs = append(errs, FieldError{Field: "email", Message: "email is invalid"})
}
if p.Services != nil && !serviceAllowed(p.Services, r.Service) {
errs = append(errs, FieldError{Field: "service", Message: "service is not a recognised value"})
}
if p.RequireMessage {
errs = append(errs, checkRunes("message", r.Message, p.MinMessageRunes, p.MaxMessageRunes)...)
}
return errs
}
// NormalizeAndValidate trims whitespace from all text fields and then
// checks the request against the built-in preset for the form type.
// Supported types: contact, feedback, newsletter, generic; an empty or
// unknown type falls back to contact. Callers that need their own limits
// or their own service allow-list build a Policy and call Validate.
func NormalizeAndValidate(r *Request, formType string) []FieldError {
return Validate(r, Preset(formType))
}
// checkRunes reports the length errors for one field in rune counts.
func checkRunes(field, value string, minRunes, maxRunes int) []FieldError {
n := utf8.RuneCountInString(value)
var errs []FieldError
if n < minRunes {
errs = append(errs, FieldError{
Field: field,
Message: fmt.Sprintf("%s must be at least %d characters", field, minRunes),
})
} else if n > maxRunes {
errs = append(errs, FieldError{
Field: field,
Message: fmt.Sprintf("%s must be at most %d characters", field, maxRunes),
})
}
return errs
}
// serviceAllowed reports whether s passes the allow-list. The empty value
// is always legitimate: petrbalvin.org and every other frontend is free to
// post a payload without a service field. The ServiceAny entry accepts any
// non-empty value.
func serviceAllowed(list []string, s string) bool {
if s == "" {
return true
}
if slices.Contains(list, ServiceAny) {
return true
}
return slices.Contains(list, s)
}
+372
View File
@@ -0,0 +1,372 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package contactform
import (
"slices"
"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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(r, "newsletter"); len(errs) > 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateNewsletter_RequiresEmail(t *testing.T) {
r := &Request{Email: "garbage"}
errs := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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 := NormalizeAndValidate(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)
}
}
// ---------------------------------------------------------------------------
// Policy-driven validation
// ---------------------------------------------------------------------------
// The presets must reproduce the exact rules the fixed validators enforced
// before the policy layer existed.
func TestPresets(t *testing.T) {
contact := Preset("contact")
if !contact.RequireName || !contact.RequireMessage {
t.Error("contact preset must require name and message")
}
if contact.MinNameRunes != MinNameRunes || contact.MaxNameRunes != MaxNameRunes {
t.Errorf("contact name limits = %d/%d, want %d/%d",
contact.MinNameRunes, contact.MaxNameRunes, MinNameRunes, MaxNameRunes)
}
if contact.MinMessageRunes != MinMessageRunes || contact.MaxMessageRunes != MaxMessageRunes {
t.Errorf("contact message limits = %d/%d, want %d/%d",
contact.MinMessageRunes, contact.MaxMessageRunes, MinMessageRunes, MaxMessageRunes)
}
if !slices.Contains(contact.Services, "architecture") || !slices.Contains(contact.Services, "other") {
t.Errorf("contact preset services = %v, want the built-in list", contact.Services)
}
for _, typ := range []string{"feedback", "generic"} {
p := Preset(typ)
if !p.RequireName || !p.RequireMessage {
t.Errorf("%s preset must require name and message", typ)
}
if p.Services != nil {
t.Errorf("%s preset must not validate service, got %v", typ, p.Services)
}
}
news := Preset("newsletter")
if news.RequireName || news.RequireMessage || news.Services != nil {
t.Errorf("newsletter preset = %+v, want email-only", news)
}
// Unknown and empty types fall back to contact.
fallback := Preset("nonsense")
if !fallback.RequireName || fallback.Services == nil {
t.Errorf("unknown type preset = %+v, want the contact preset", fallback)
}
}
// A payload without a service field is legitimate under the contact
// preset: the field is optional, never a mandatory category.
func TestValidateContactWithoutServiceField(t *testing.T) {
r := &Request{Name: "Jane Doe", Email: "jane@example.com", Message: "A plain hello for you."}
if errs := NormalizeAndValidate(r, "contact"); len(errs) > 0 {
t.Fatalf("payload without service must pass, got %v", errs)
}
}
// A nil Services list means the field is not validated at all, so any
// value rides through untouched.
func TestPolicyNilServicesSkipsValidation(t *testing.T) {
p := Preset("feedback")
r := &Request{Name: "Jane", Email: "jane@example.com", Service: "anything-goes", Message: "A message long enough."}
if errs := Validate(r, p); len(errs) > 0 {
t.Fatalf("nil services list must skip the field, got %v", errs)
}
}
func TestPolicyServices(t *testing.T) {
base := func(services []string, service string) []FieldError {
p := Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: 2, MaxNameRunes: 100,
MinMessageRunes: 10, MaxMessageRunes: 5000,
Services: services,
}
r := &Request{Name: "Jane", Email: "jane@example.com", Service: service, Message: "A message long enough."}
return Validate(r, p)
}
tests := []struct {
name string
services []string
service string
wantErr bool
}{
{"empty service always passes", []string{"consulting"}, "", false},
{"listed value passes", []string{"consulting", "support"}, "support", false},
{"unlisted value fails", []string{"consulting"}, "hairstyling", true},
{"explicit empty list rejects any value", []string{}, "consulting", true},
{"explicit empty list keeps empty value", []string{}, "", false},
{"wildcard accepts anything", []string{ServiceAny}, "hairstyling", false},
{"wildcard beside entries still accepts anything", []string{"consulting", ServiceAny}, "anything", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errs := base(tt.services, tt.service)
if gotErr := len(errs) > 0; gotErr != tt.wantErr {
t.Fatalf("Validate(service=%q, list=%v) errors = %v, wantErr %v",
tt.service, tt.services, errs, tt.wantErr)
}
if tt.wantErr && errs[0].Field != "service" {
t.Errorf("error field = %q, want service", errs[0].Field)
}
})
}
}
// Custom limits are honoured and the error messages carry the configured
// numbers, not the built-in defaults.
func TestPolicyCustomLimits(t *testing.T) {
p := Policy{
RequireName: true,
RequireMessage: true,
MinNameRunes: 5,
MaxNameRunes: 10,
MinMessageRunes: 20,
MaxMessageRunes: 40,
}
r := &Request{Name: "Ja", Email: "jane@example.com", Message: "too short"}
errs := Validate(r, p)
if len(errs) != 2 {
t.Fatalf("errors = %v, want two length failures", errs)
}
if errs[0].Field != "name" || !strings.Contains(errs[0].Message, "at least 5 characters") {
t.Errorf("name error = %+v, want the configured minimum", errs[0])
}
if errs[1].Field != "message" || !strings.Contains(errs[1].Message, "at least 20 characters") {
t.Errorf("message error = %+v, want the configured minimum", errs[1])
}
r2 := &Request{Name: "A very long name over the limit", Email: "jane@example.com",
Message: strings.Repeat("x", 41)}
errs = Validate(r2, p)
if len(errs) != 2 {
t.Fatalf("errors = %v, want two maximum failures", errs)
}
if !strings.Contains(errs[0].Message, "at most 10 characters") ||
!strings.Contains(errs[1].Message, "at most 40 characters") {
t.Errorf("errors = %v, want the configured maxima", errs)
}
}
// A false Require flag takes the whole field out of the checks; the field
// is still trimmed so the email body sees clean text.
func TestPolicyOptionalFields(t *testing.T) {
p := Policy{RequireName: false, RequireMessage: false, Services: []string{ServiceAny}}
r := &Request{Name: "", Email: "jane@example.com", Message: "", Service: "x"}
if errs := Validate(r, p); len(errs) > 0 {
t.Fatalf("optional fields must pass empty, got %v", errs)
}
if r.Name != "" || r.Message != "" || r.Service != "x" {
t.Errorf("fields should stay trimmed, got %+v", r)
}
}
// The email address is never optional: it is the reply-to of the mail the
// form produces.
func TestPolicyEmailAlwaysRequired(t *testing.T) {
p := Policy{}
r := &Request{Name: "Jane", Email: "not-an-email", Message: "hello"}
errs := Validate(r, p)
if len(errs) != 1 || errs[0].Field != "email" {
t.Fatalf("errors = %v, want exactly the email failure", errs)
}
}
// Validate must produce the same verdicts as the type dispatcher did
// before the policy layer: every case the old tests covered still holds
// through the wrapper.
func TestNormalizeAndValidateBackwardCompatible(t *testing.T) {
cases := []struct {
name string
formType string
req Request
wantErr bool
}{
{"contact happy", "contact", Request{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss a project."}, false},
{"contact bad service", "contact", Request{Name: "Jane", Email: "jane@example.com", Service: "hairstyling", Message: "A message long enough."}, true},
{"feedback happy", "feedback", Request{Name: "Jane", Email: "jane@example.com", Message: "A message long enough."}, false},
{"generic short message", "generic", Request{Name: "Jane", Email: "jane@example.com", Message: "short"}, true},
{"newsletter happy", "newsletter", Request{Email: "jane@example.com"}, false},
{"newsletter bad email", "newsletter", Request{Email: "garbage"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := tc.req
errs := NormalizeAndValidate(&req, tc.formType)
if got := len(errs) > 0; got != tc.wantErr {
t.Fatalf("NormalizeAndValidate(%q) errors = %v, wantErr %v", tc.formType, errs, tc.wantErr)
}
})
}
}
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 := NormalizeAndValidate(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)
}
}
}
// FuzzValidate explores the validator with arbitrary payloads and
// policies. The invariants: it never panics, every reported error names
// one of the four known fields with a non-empty message, and validation
// is deterministic in the trimmed request.
func FuzzValidate(f *testing.F) {
seeds := []Request{
{Name: "Jane Doe", Email: "jane@example.com", Service: "architecture", Message: "Hello, I would like to discuss an engagement."},
{Name: "", Email: "", Service: "", Message: ""},
{Name: " J ", Email: " j@example.com ", Service: " unix ", Message: "x"},
{Name: "<script>", Email: "a@b", Service: "unknown", Message: strings.Repeat("m", 6000)},
{Name: "Elf", Email: "u@exämple.com", Service: "*", Message: "emoji 🚀 body"},
}
for _, s := range seeds {
f.Add(s.Name, s.Email, s.Service, s.Message, true, true, 2, 100, 10, 5000, 0)
}
f.Add("A", "a@b.c", "", "long enough", false, false, 0, 1, 0, 1, 3)
knownFields := []string{"name", "email", "service", "message"}
serviceLists := [][]string{nil, {}, {"architecture", "unix"}, {"*"}}
f.Fuzz(func(t *testing.T, name, email, service, message string,
requireName, requireMessage bool,
minName, maxName, minMessage, maxMessage, serviceChoice int) {
policy := Policy{
RequireName: requireName,
RequireMessage: requireMessage,
MinNameRunes: minName,
MaxNameRunes: maxName,
MinMessageRunes: minMessage,
MaxMessageRunes: maxMessage,
Services: serviceLists[((serviceChoice%len(serviceLists))+len(serviceLists))%len(serviceLists)],
}
req := Request{Name: name, Email: email, Service: service, Message: message}
errs := Validate(&req, policy)
for _, e := range errs {
if !slices.Contains(knownFields, e.Field) {
t.Fatalf("unknown field %q in error %q", e.Field, e.Message)
}
if strings.TrimSpace(e.Message) == "" {
t.Fatalf("empty message on field %q", e.Field)
}
}
again := Validate(&req, policy)
if !slices.Equal(errs, again) {
t.Fatalf("validation is not deterministic: %v then %v", errs, again)
}
})
}
+120
View File
@@ -0,0 +1,120 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"bytes"
"fmt"
"html/template"
"net/smtp"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
// emailTemplateAcknowledgement is the HTML body of the automated receipt
// a form with auto_reply enabled sends to the submitter.
var emailTemplateAcknowledgement = template.Must(template.New("acknowledgement").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">Message received</p>
</td></tr>
<tr><td style="padding:24px">
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0 0 12px">Hello,</p>
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0 0 12px">your message to <strong>{{.FormName}}</strong> was received. A reply will follow as soon as possible.</p>
<p style="font-size:14px;line-height:1.6;color:#1f2937;margin:0">Please do not respond to this automated receipt.</p>
{{if .Brand}}
<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">{{.Brand}}</strong></p>
{{end}}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// SendAcknowledgement mails the submitter a short receipt. It uses the
// form's SMTP identity, and the Reply-To points at the owner, so a reply
// to the receipt lands in the human's inbox and not into the void.
func (s *FormSender) SendAcknowledgement(to string) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg, err := composeAcknowledgement(s.form, to)
if err != nil {
return fmt.Errorf("compose acknowledgement: %w", err)
}
return sendMail(s.form.SMTP, auth, s.form.From, []string{to}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
}
// composeAcknowledgement builds the multipart receipt. The submitted
// values are deliberately not echoed back: the receipt confirms arrival,
// it does not mirror a message's content into the submitter's inbox,
// where any third party who could fill the form would read it.
func composeAcknowledgement(form *config.Form, to string) ([]byte, error) {
// --- HTML part ---
var htmlBuf bytes.Buffer
if err := emailTemplateAcknowledgement.Execute(&htmlBuf, map[string]string{
"FormName": form.Name,
"Brand": form.Brand(),
}); err != nil {
// template.Must guarantees valid templates; unreachable in
// normal operation.
htmlBuf.Reset()
fmt.Fprintf(&htmlBuf, "<p>Email generation error: %v</p>", err)
}
// --- Subject + plain-text body ---
subject := "Message received"
if tag := subjectTag(form, ""); tag != "" {
subject = tag + " " + subject
}
footer := ""
if brand := form.Brand(); brand != "" {
footer = "--\r\nDelivered by " + brand + "\r\n"
}
text := fmt.Sprintf(
"Hello,\r\n\r\n"+
"your message to %s was received. A reply will follow as soon as possible.\r\n"+
"Please do not respond to this automated receipt.\r\n\r\n%s",
form.Name, footer,
)
// Assemble multipart/alternative. The boundary comes first so that
// randomness failure aborts the message before anything is built.
boundary, err := randomBoundary()
if err != nil {
return nil, err
}
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderValue(form.From)))
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderValue(to)))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitizeHeaderValue(subject)))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", sanitizeHeaderValue(form.To)))
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(), nil
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
func ackForm() *config.Form {
return &config.Form{
Name: "contact",
Type: "contact",
To: "owner@example.com",
From: "noreply@example.com",
SubjectPrefix: new("nuntius"),
EmailBrand: new("nuntius"),
}
}
func TestComposeAcknowledgement(t *testing.T) {
msg, err := composeAcknowledgement(ackForm(), "jane@example.com")
if err != nil {
t.Fatalf("compose: %v", err)
}
raw := string(msg)
for _, want := range []string{
"From: noreply@example.com\r\n",
"To: jane@example.com\r\n",
"Subject: [nuntius/contact] Message received\r\n",
// The reply lands in the owner's inbox, not in the void.
"Reply-To: owner@example.com\r\n",
"Content-Type: multipart/alternative; boundary=nuntius-",
"your message to contact was received",
"Delivered by nuntius",
} {
if !strings.Contains(raw, want) {
t.Errorf("message missing %q", want)
}
}
// The submitted values are deliberately never echoed back.
if strings.Contains(raw, "jane@example.com\r\n\r\n") && strings.Count(raw, "jane@example.com") != 1 {
t.Errorf("message echoes the submitter context beyond the To header")
}
}
func TestComposeAcknowledgementHeaderInjection(t *testing.T) {
msg, err := composeAcknowledgement(ackForm(), "jane@example.com\r\nBcc: victim@example.com")
if err != nil {
t.Fatalf("compose: %v", err)
}
raw := string(msg)
// The whole string collapses into one To header line: no line starts
// with the injected header name.
for line := range strings.SplitSeq(raw, "\r\n") {
if strings.HasPrefix(line, "Bcc:") {
t.Errorf("a CR/LF in the recipient injected a header line %q", line)
}
}
if !strings.Contains(raw, "To: jane@example.com Bcc: victim@example.com\r\n") {
t.Errorf("the CR/LF was not neutralised into spaces")
}
}
func TestComposeAcknowledgementSubjectWithoutPrefix(t *testing.T) {
form := ackForm()
form.SubjectPrefix = new("")
msg, err := composeAcknowledgement(form, "jane@example.com")
if err != nil {
t.Fatalf("compose: %v", err)
}
if !strings.Contains(string(msg), "Subject: Message received\r\n") {
t.Errorf("subject with an empty prefix = %q, want the bare subject", subjectOf(t, string(msg)))
}
}
func subjectOf(t *testing.T, raw string) string {
t.Helper()
for line := range strings.SplitSeq(raw, "\r\n") {
if after, ok := strings.CutPrefix(line, "Subject: "); ok {
return after
}
}
t.Fatalf("no subject line in message")
return ""
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"bytes"
"fmt"
"net/smtp"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
// SendConfirmation mails the double opt-in link directly to the subscriber.
// It uses the form's SMTP identity so replies land at the owner address,
// which is set as Reply-To.
func (s *FormSender) SendConfirmation(to, link string) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg := composeConfirmation(s.form.From, to, s.form.To, s.form, link)
return sendMail(s.form.SMTP, auth, s.form.From, []string{to}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
}
// composeConfirmation builds a single-part plain-text message. Transactional
// confirmations stay deliberately simple: one link, no tracking, no HTML.
// The subject prefix and the footer brand come from the form's
// configuration; the defaults reproduce the earlier wording.
func composeConfirmation(from, to, replyToOwner string, form *config.Form, link string) []byte {
subject := "Confirm your subscription"
if prefix := form.EmailSubjectPrefix(); prefix != "" {
subject = fmt.Sprintf("[%s/%s] %s", prefix, form.Name, subject)
}
var body bytes.Buffer
fmt.Fprintf(&body, "Hi,\r\n\r\n")
fmt.Fprintf(&body, "someone signed this address up for the \"%s\" form.\r\n", form.Name)
fmt.Fprintf(&body, "If that was you, please confirm the subscription by opening:\r\n\r\n")
fmt.Fprintf(&body, " %s\r\n\r\n", link)
fmt.Fprintf(&body, "If it was not you, ignore this message and nothing will happen:\r\n")
fmt.Fprintf(&body, "the request expires automatically without any action from you.\r\n\r\n")
if brand := form.Brand(); brand != "" {
fmt.Fprintf(&body, "Delivered by %s\r\n", brand)
}
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderValue(from)))
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderValue(to)))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitizeHeaderValue(subject)))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", sanitizeHeaderValue(replyToOwner)))
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
msg.WriteString("Content-Transfer-Encoding: 8bit\r\n")
msg.WriteString("\r\n")
msg.Write(body.Bytes())
return msg.Bytes()
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
func TestComposeConfirmation(t *testing.T) {
form := &config.Form{Name: "newsletter"}
msg := string(composeConfirmation("noreply@example.com", "jane@example.com",
"owner@example.com", form,
"https://example.com/api/news/confirm?token=abc123"))
headers := msg[:strings.Index(msg, "\r\n\r\n")]
if !strings.Contains(headers, "Subject: [nuntius/newsletter] Confirm your subscription") {
t.Errorf("subject missing in:\n%s", headers)
}
if strings.Count(headers, "Subject:") != 1 {
t.Errorf("exactly one Subject header expected:\n%s", headers)
}
if !strings.Contains(headers, "Reply-To: owner@example.com") {
t.Errorf("Reply-To should reach the owner:\n%s", headers)
}
if !strings.Contains(msg, "https://example.com/api/news/confirm?token=abc123") {
t.Error("confirmation link missing from the body")
}
if !strings.Contains(msg, "Delivered by nuntius\r\n") {
t.Error("default brand footer missing from the body")
}
}
// A configured prefix and brand replace the default nuntius wording; an
// empty prefix drops the bracket segment and an empty brand drops the
// footer line entirely.
func TestComposeConfirmationPrefixAndBrand(t *testing.T) {
form := &config.Form{
Name: "news",
SubjectPrefix: new("web"),
EmailBrand: new("Acme Mail"),
}
msg := string(composeConfirmation("noreply@example.com", "jane@example.com",
"owner@example.com", form, "https://example.com/confirm?token=abc"))
if !strings.Contains(msg, "Subject: [web/news] Confirm your subscription") {
t.Errorf("configured prefix missing:\n%s", msg)
}
if !strings.Contains(msg, "Delivered by Acme Mail\r\n") {
t.Errorf("configured brand missing:\n%s", msg)
}
form.SubjectPrefix = new("")
form.EmailBrand = new("")
msg = string(composeConfirmation("noreply@example.com", "jane@example.com",
"owner@example.com", form, "https://example.com/confirm?token=abc"))
if !strings.Contains(msg, "Subject: Confirm your subscription\r\n") {
t.Errorf("empty prefix must drop the bracket segment:\n%s", msg)
}
if strings.Contains(msg, "Delivered by") {
t.Errorf("empty brand must drop the footer:\n%s", msg)
}
}
+494
View File
@@ -0,0 +1,494 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package email handles SMTP message composition and delivery.
package email
import (
"bytes"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"html/template"
"net"
"net/smtp"
"strings"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
// effectiveTLSConfig returns cfg when set, otherwise a default config that
// verifies the server certificate against host.
func effectiveTLSConfig(cfg *tls.Config, host string) *tls.Config {
if cfg != nil {
return cfg
}
return &tls.Config{ServerName: host}
}
// 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
// TLSConfig optionally overrides the TLS configuration used for
// STARTTLS. When nil, a default config with ServerName set to the
// SMTP host is used.
TLSConfig *tls.Config
}
// 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 composition or the SMTP
// round-trip fails. The SMTP conversation bound comes from the form's
// configured smtp.timeout_seconds.
func (s *FormSender) Send(req contactform.Request) error {
auth := smtp.PlainAuth("", s.form.SMTP.User, s.form.SMTP.Password, s.form.SMTP.Host)
msg, err := compose(s.form.From, s.form.To, req, s.form)
if err != nil {
return fmt.Errorf("compose message: %w", err)
}
return sendMail(s.form.SMTP, auth, s.form.From, []string{s.form.To}, msg, s.form.SMTP.Timeout(), s.TLSConfig)
}
// sendMail delivers msg over SMTP with a hard timeout on every network
// operation. When cfg.Port is 465 the connection speaks TLS from the first
// byte (implicit TLS); any other port starts plaintext and upgrades via
// STARTTLS when the server advertises it. With cfg.RequireTLS set, an SMTP
// server that never offers STARTTLS aborts the delivery instead of sending
// over plaintext. If tlsConfig is nil, a default config with ServerName set
// to the target host is used.
func sendMail(cfg config.SMTPConfig, auth smtp.Auth, from string, to []string, msg []byte, timeout time.Duration, tlsConfig *tls.Config) error {
addr := cfg.AddrFor()
implicitTLS := cfg.Port == config.ImplicitTLSPort
var conn net.Conn
if implicitTLS {
dialer := &net.Dialer{Timeout: timeout}
tconn, err := tls.DialWithDialer(dialer, "tcp", addr, effectiveTLSConfig(tlsConfig, cfg.Host))
if err != nil {
return fmt.Errorf("dial smtps %s: %w", addr, err)
}
conn = tconn
} else {
pconn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("dial smtp %s: %w", addr, err)
}
conn = pconn
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
return fmt.Errorf("set smtp deadline: %w", err)
}
c, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if !implicitTLS {
ok, _ := c.Extension("STARTTLS")
switch {
case ok:
if err := c.StartTLS(effectiveTLSConfig(tlsConfig, cfg.Host)); err != nil {
return fmt.Errorf("starttls: %w", err)
}
case cfg.RequireTLS:
return fmt.Errorf("smtp server %s does not advertise starttls but require_tls is enabled", addr)
}
}
if auth != nil {
if ok, _ := c.Extension("AUTH"); ok {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp mail from: %w", err)
}
for _, rcpt := range to {
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp rcpt to: %w", err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp data: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("smtp write data: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp close data: %w", err)
}
if err := c.Quit(); err != nil {
return fmt.Errorf("smtp quit: %w", err)
}
return nil
}
// randomBoundary returns a MIME boundary that is practically impossible
// to collide with message content. An error means crypto/rand failed; the
// message must then not be sent at all, because a predictable delimiter
// would let crafted content forge MIME part boundaries.
func randomBoundary() (string, error) {
var buf [16]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("generate mime boundary: %w", err)
}
return "nuntius-" + hex.EncodeToString(buf[:]), nil
}
// sanitizeHeaderValue makes an interpolated value safe to embed in a
// single RFC 5322 header line. A CR or LF would terminate the header and
// let a crafted value inject arbitrary additional headers.
func sanitizeHeaderValue(v string) string {
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
}
// 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 -->
{{if .Brand}}
<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">{{.Brand}}</strong>, a contact form backend for Linux servers.
</p>
</td>
</tr>
{{end}}
</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>
{{if .Service}}
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Service Interest</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;margin:0 0 16px">{{.Service}}</div>
{{end}}
<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>
{{if .Brand}}
<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">{{.Brand}}</strong></p>
{{end}}
</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>
{{if .Brand}}
<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">{{.Brand}}</strong></p>
{{end}}
</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>
{{if .Service}}
<p style="font-size:11px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:.5px;margin:0 0 8px">Service Interest</p>
<div style="font-size:14px;line-height:1.6;color:#1f2937;white-space:pre-wrap;margin:0 0 16px">{{.Service}}</div>
{{end}}
<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>
{{if .Brand}}
<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">{{.Brand}}</strong></p>
{{end}}
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`))
// compose builds the multipart message for one submission. The subject
// prefix and the footer brand come from the form's configuration; the
// defaults reproduce the subjects and footers earlier releases sent.
func compose(from, to string, req contactform.Request, form *config.Form) ([]byte, error) {
received := time.Now().UTC().Format("January 2, 2006 at 15:04 UTC")
// The service interest only surfaces when the form actually accepts
// the field: the contact template has always shown it, and a form
// with its own services list has opted the field into validation.
service := ""
if req.Service != "" && (form.Type == "contact" || form.Services != nil) {
service = req.Service
}
// --- HTML part ---
var htmlBuf bytes.Buffer
tmpl := emailTemplateContact
switch form.Type {
case "newsletter":
tmpl = emailTemplateNewsletter
case "feedback":
tmpl = emailTemplateFeedback
case "generic":
tmpl = emailTemplateGeneric
}
if err := tmpl.Execute(&htmlBuf, map[string]string{
"FormName": form.Name,
"Name": req.Name,
"Email": req.Email,
"Service": service,
"Message": req.Message,
"Received": received,
"Brand": form.Brand(),
}); err != nil {
// template.Must guarantees valid templates; this path is
// unreachable in normal operation.
htmlBuf.Reset()
fmt.Fprintf(&htmlBuf, "<p>Email generation error: %v</p>", err)
}
// --- Subject + plain-text body per form type ---
var base string
switch form.Type {
case "newsletter":
base = "New newsletter subscriber"
case "feedback":
base = "New feedback"
case "generic":
base = "New submission"
default: // contact
base = "Contact form submission"
}
subject := base
if tag := subjectTag(form, service); tag != "" {
subject = tag + " " + base
}
footer := ""
if brand := form.Brand(); brand != "" {
footer = "Delivered by " + brand + "\r\n"
}
var text string
switch form.Type {
case "newsletter":
text = fmt.Sprintf(
"New Newsletter Subscriber: %s\r\n"+
"---\r\n"+
"Email: %s\r\n"+
"Received: %s\r\n"+
"\r\n",
form.Name, req.Email, received,
)
case "feedback":
text = fmt.Sprintf(
"New Feedback: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"%s"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n",
form.Name, req.Name, req.Email, serviceLine(service), received, req.Message,
)
case "generic":
text = fmt.Sprintf(
"New Submission: %s\r\n"+
"---\r\n"+
"From: %s <%s>\r\n"+
"%s"+
"Received: %s\r\n"+
"\r\n"+
"%s\r\n\r\n",
form.Name, req.Name, req.Email, serviceLine(service), received, req.Message,
)
default: // contact; the line is printed even when empty, as always
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",
form.Name, req.Name, req.Email, req.Service, received, req.Message,
)
}
text += footer
// Assemble multipart/alternative message. The boundary comes first so
// that randomness failure aborts the message before anything is built.
boundary, err := randomBoundary()
if err != nil {
return nil, err
}
var msg bytes.Buffer
msg.WriteString(fmt.Sprintf("From: %s\r\n", sanitizeHeaderValue(from)))
msg.WriteString(fmt.Sprintf("To: %s\r\n", sanitizeHeaderValue(to)))
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", sanitizeHeaderValue(subject)))
msg.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z)))
msg.WriteString(fmt.Sprintf("Reply-To: %s\r\n", sanitizeHeaderValue(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(), nil
}
// subjectTag renders the bracket segments of a subject line: the
// configurable "[<prefix>/<form name>]" segment when a prefix is set,
// plus the "[<service>]" segment when a service value surfaced.
func subjectTag(form *config.Form, service string) string {
tag := ""
if prefix := form.EmailSubjectPrefix(); prefix != "" {
tag = "[" + prefix + "/" + form.Name + "]"
}
if service != "" {
tag += "[" + service + "]"
}
return tag
}
// serviceLine renders the optional plain-text service row for the form
// types whose body has no fixed service field.
func serviceLine(service string) string {
if service == "" {
return ""
}
return fmt.Sprintf("Service interest: %s\r\n", service)
}
+623
View File
@@ -0,0 +1,623 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package email
import (
"bufio"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"net"
"net/smtp"
"strconv"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
// mustCompose wraps compose for tests: a composition failure is always a
// bug, not a case worth branching on. The bare form carries no policy
// keys, so the composition defaults apply.
func mustCompose(t *testing.T, from, to string, req contactform.Request, formName, formType string) []byte {
t.Helper()
b, err := compose(from, to, req, &config.Form{Name: formName, Type: formType})
if err != nil {
t.Fatalf("compose: %v", err)
}
return b
}
func TestComposeContactWithoutService(t *testing.T) {
req := contactform.Request{
Name: "Alice",
Email: "alice@example.com",
Message: "I have a question about your services.",
}
b := mustCompose(t, "from@example.com", "to@example.com", req, "MyForm", "contact")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/MyForm] Contact form submission") {
t.Errorf("expected subject with form name only, got body:\n%s", s)
}
if !strings.Contains(s, "From: from@example.com") {
t.Error("expected From header")
}
if !strings.Contains(s, "To: to@example.com") {
t.Error("expected To header")
}
if !strings.Contains(s, "Reply-To: alice@example.com") {
t.Error("expected Reply-To header")
}
if !strings.Contains(s, "MIME-Version: 1.0") {
t.Error("expected MIME-Version header")
}
if !strings.Contains(s, "Content-Type: text/plain; charset=UTF-8") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html; charset=UTF-8") {
t.Error("expected text/html part")
}
if !strings.Contains(s, "Content-Type: multipart/alternative") {
t.Error("expected multipart/alternative content type")
}
if !strings.Contains(s, "I have a question about your services.") {
t.Error("expected message content in body")
}
if !strings.Contains(s, "Contact Form Submission:") {
t.Error("expected contact form plain-text header")
}
}
func TestComposeContactWithService(t *testing.T) {
req := contactform.Request{
Name: "Bob",
Email: "bob@example.com",
Service: "architecture",
Message: "I would like a consultation.",
}
b := mustCompose(t, "from@e.com", "to@e.com", req, "ContactForm", "contact")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/ContactForm][architecture] Contact form submission") {
t.Errorf("expected subject with service tag, got body:\n%s", s)
}
if !strings.Contains(s, "Service interest: architecture") {
t.Error("expected service interest in plain text")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part")
}
if !strings.Contains(s, "bob@example.com") {
t.Error("expected submitter email in body")
}
}
func TestComposeFeedback(t *testing.T) {
req := contactform.Request{
Name: "Carol",
Email: "carol@example.com",
Message: "Great platform, but can you add dark mode?",
}
b := mustCompose(t, "sender@h.com", "recv@h.com", req, "FeedbackForm", "feedback")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/FeedbackForm] New feedback") {
t.Errorf("expected feedback subject, got body:\n%s", s)
}
if !strings.Contains(s, "New Feedback:") {
t.Error("expected feedback plain-text header")
}
if !strings.Contains(s, "From: Carol <carol@example.com>") {
t.Error("expected From line in plain text")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part")
}
}
func TestComposeNewsletter(t *testing.T) {
req := contactform.Request{
Email: "subscriber@example.com",
}
b := mustCompose(t, "news@h.com", "owner@h.com", req, "NewsletterSignup", "newsletter")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/NewsletterSignup] New newsletter subscriber") {
t.Errorf("expected newsletter subject, got body:\n%s", s)
}
if !strings.Contains(s, "New Newsletter Subscriber:") {
t.Error("expected newsletter plain-text header")
}
if !strings.Contains(s, "Email: subscriber@example.com") {
t.Error("expected subscriber email in plain text")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part")
}
}
func TestComposeGeneric(t *testing.T) {
req := contactform.Request{
Name: "Dave",
Email: "dave@example.com",
Message: "Generic inquiry.",
}
b := mustCompose(t, "g@h.com", "g@h.com", req, "GenericForm", "generic")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/GenericForm] New submission") {
t.Errorf("expected generic subject, got body:\n%s", s)
}
if !strings.Contains(s, "New Submission:") {
t.Error("expected generic plain-text header")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part")
}
}
func TestComposeEmptyFormNameAndEmail(t *testing.T) {
req := contactform.Request{
Name: "",
Email: "",
Message: "",
}
b := mustCompose(t, "", "", req, "", "generic")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/] New submission") {
t.Errorf("expected subject with empty form name, got body:\n%s", s)
}
if !strings.Contains(s, "MIME-Version: 1.0") {
t.Error("expected MIME-Version header even with empty fields")
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part even with empty fields")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part even with empty fields")
}
}
func TestComposeLongMessage(t *testing.T) {
longMsg := strings.Repeat("Lorem ipsum dolor sit amet. ", 200)
req := contactform.Request{
Name: "Eve",
Email: "eve@example.com",
Message: longMsg,
}
b := mustCompose(t, "x@y.com", "z@y.com", req, "LongForm", "feedback")
s := string(b)
if !strings.Contains(s, longMsg) {
t.Error("expected long message content in body")
}
// Verify it's in both text and html parts by checking after the respective
// content-type boundaries.
textIdx := strings.Index(s, "Content-Type: text/plain")
htmlIdx := strings.Index(s, "Content-Type: text/html")
if textIdx == -1 || htmlIdx == -1 {
t.Fatal("expected both text/plain and text/html parts")
}
textPart := s[textIdx:htmlIdx]
htmlPart := s[htmlIdx:]
if !strings.Contains(textPart, longMsg) {
t.Error("expected long message in text/plain part")
}
if !strings.Contains(htmlPart, longMsg) {
t.Error("expected long message in text/html part")
}
}
func TestComposeSpecialCharacters(t *testing.T) {
specialMsg := "Café résumé, déjà vu\nLine\twith\ttabs\n€uro sign © 2026"
req := contactform.Request{
Name: "Renée",
Email: "renée@example.com",
Message: specialMsg,
}
b := mustCompose(t, "ñ@c.com", "ö@c.com", req, "SpaForm", "contact")
s := string(b)
if !strings.Contains(s, "Café résumé, déjà vu") {
t.Error("expected accented characters to survive round-trip")
}
if !strings.Contains(s, "€uro sign © 2026") {
t.Error("expected special symbols to survive round-trip")
}
if !strings.Contains(s, "Renée") {
t.Error("expected accented name in body")
}
if !strings.Contains(s, "MIME-Version: 1.0") {
t.Error("expected MIME-Version header")
}
}
func TestComposeUnknownFormTypeFallsBackToContact(t *testing.T) {
// Unknown form type should default to the contact template.
req := contactform.Request{
Name: "Fallback",
Email: "fallback@example.com",
Message: "Does this work?",
}
b := mustCompose(t, "a@b.com", "c@b.com", req, "UnknownForm", "nonexistent")
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/UnknownForm] Contact form submission") {
t.Errorf("expected contact fallback subject, got body:\n%s", s)
}
if !strings.Contains(s, "Content-Type: text/plain") {
t.Error("expected text/plain part in fallback")
}
if !strings.Contains(s, "Content-Type: text/html") {
t.Error("expected text/html part in fallback")
}
}
func TestComposeAllHeadersPresent(t *testing.T) {
req := contactform.Request{
Name: "Test",
Email: "test@example.com",
Message: "Checking headers.",
}
b := mustCompose(t, "from@x.com", "to@x.com", req, "HeaderForm", "contact")
s := string(b)
required := []string{
"From: from@x.com",
"To: to@x.com",
"Subject:",
"Date:",
"Reply-To: test@example.com",
"MIME-Version: 1.0",
"Content-Type: multipart/alternative",
}
for _, h := range required {
if !strings.Contains(s, h) {
t.Errorf("expected header %q in message", h)
}
}
}
func TestComposeMultipartBoundary(t *testing.T) {
req := contactform.Request{
Name: "Boundary",
Email: "boundary@example.com",
Message: "Boundary test.",
}
b := mustCompose(t, "from@t.com", "to@t.com", req, "BoundForm", "contact")
s := string(b)
// The boundary is random per message; verify structure, not a fixed value.
if !strings.Contains(s, "Content-Type: multipart/alternative; boundary=nuntius-") {
t.Error("expected multipart/alternative with nuntius- prefixed boundary")
}
// Extract the boundary token and verify opening, middle and closing markers.
idx := strings.Index(s, "boundary=nuntius-")
if idx == -1 {
t.Fatal("boundary token not found")
}
boundary := s[idx+len("boundary="):]
if end := strings.IndexByte(boundary, '\r'); end >= 0 {
boundary = boundary[:end]
}
if count := strings.Count(s, "--"+boundary); count < 3 {
t.Errorf("expected at least 3 boundary markers for %q, got %d", boundary, count)
}
if !strings.Contains(s, "--"+boundary+"--") {
t.Error("expected closing boundary marker")
}
}
func TestComposeDeliveredByFooter(t *testing.T) {
req := contactform.Request{
Name: "Footer",
Email: "footer@example.com",
Message: "Footer check.",
}
for _, ft := range []string{"contact", "feedback", "newsletter", "generic"} {
b := mustCompose(t, "f@t.com", "t@t.com", req, "FooterForm", ft)
s := string(b)
if !strings.Contains(s, "Delivered by nuntius") {
t.Errorf("form type %q: expected 'Delivered by nuntius' footer in plain text", ft)
}
}
}
// A configured subject prefix and brand replace the default nuntius
// wording in both the plain-text and the HTML part.
func TestComposePrefixAndBrand(t *testing.T) {
req := contactform.Request{
Name: "Alice",
Email: "alice@example.com",
Message: "A custom branding check.",
}
form := &config.Form{
Name: "MyForm",
Type: "feedback",
SubjectPrefix: new("web"),
EmailBrand: new("Acme Mail"),
}
b, err := compose("from@example.com", "to@example.com", req, form)
if err != nil {
t.Fatalf("compose: %v", err)
}
s := string(b)
if !strings.Contains(s, "Subject: [web/MyForm] New feedback") {
t.Errorf("configured prefix missing from subject:\n%s", s)
}
if !strings.Contains(s, "Delivered by Acme Mail") {
t.Error("configured brand missing from the plain-text footer")
}
if !strings.Contains(s, "Delivered by <strong style=\"color:#6b7280\">Acme Mail</strong>") {
t.Error("configured brand missing from the HTML footer")
}
// An empty prefix and brand drop the segments entirely.
form.SubjectPrefix = new("")
form.EmailBrand = new("")
b, err = compose("from@example.com", "to@example.com", req, form)
if err != nil {
t.Fatalf("compose: %v", err)
}
s = string(b)
if !strings.Contains(s, "Subject: New feedback\r\n") {
t.Errorf("empty prefix must drop the bracket segment:\n%s", s)
}
if strings.Contains(s, "Delivered by") {
t.Errorf("empty brand must drop the footer:\n%s", s)
}
}
// A service value configured onto a non-contact form surfaces in the
// subject tag, the plain-text body and the HTML body.
func TestComposeServiceOnFeedback(t *testing.T) {
req := contactform.Request{
Name: "Alice",
Email: "alice@example.com",
Service: "bug",
Message: "A service-aware feedback.",
}
form := &config.Form{
Name: "MyForm",
Type: "feedback",
Services: []string{"bug", "idea"},
}
b, err := compose("from@example.com", "to@example.com", req, form)
if err != nil {
t.Fatalf("compose: %v", err)
}
s := string(b)
if !strings.Contains(s, "Subject: [nuntius/MyForm][bug] New feedback") {
t.Errorf("service tag missing from subject:\n%s", s)
}
if !strings.Contains(s, "Service interest: bug") {
t.Error("service line missing from the plain-text body")
}
if !strings.Contains(s, "Service Interest") {
t.Error("service block missing from the HTML body")
}
}
// ---------------------------------------------------------------------------
// SMTP delivery tests
// ---------------------------------------------------------------------------
func selfSignedCert(t *testing.T) tls.Certificate {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}
}
// startFakeSMTP runs a minimal SMTP server that supports STARTTLS and AUTH.
// It returns the listen address.
func startFakeSMTP(t *testing.T, cert tls.Certificate) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
fmt.Fprintf(conn, "220 fake ESMTP\r\n")
reader := bufio.NewReader(conn)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
fmt.Fprintf(conn, "250-fake\r\n250-STARTTLS\r\n250 AUTH PLAIN\r\n")
case strings.HasPrefix(line, "STARTTLS"):
fmt.Fprintf(conn, "220 Ready to start TLS\r\n")
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{cert}})
if err := tlsConn.Handshake(); err != nil {
return
}
conn = tlsConn
reader = bufio.NewReader(conn)
case strings.HasPrefix(line, "AUTH"):
fmt.Fprintf(conn, "235 Authentication successful\r\n")
case strings.HasPrefix(line, "MAIL FROM:"), strings.HasPrefix(line, "RCPT TO:"):
fmt.Fprintf(conn, "250 OK\r\n")
case strings.HasPrefix(line, "DATA"):
fmt.Fprintf(conn, "354 End data with <CR><LF>.<CR><LF>\r\n")
for {
dataLine, err := reader.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(dataLine) == "." {
break
}
}
fmt.Fprintf(conn, "250 OK\r\n")
case strings.HasPrefix(line, "QUIT"):
fmt.Fprintf(conn, "221 Bye\r\n")
return
default:
fmt.Fprintf(conn, "250 OK\r\n")
}
}
}()
return ln.Addr().String()
}
// A validated email may still contain characters that would terminate a
// header line; composition must neutralise them defensively in the header
// block. Body content is free-form and delimited by the random boundary.
func TestComposeSanitisesHeaderInjection(t *testing.T) {
req := contactform.Request{
Name: "Eve",
Email: "eve@example.com",
Message: "Hello there, this is fine.",
}
b := mustCompose(t, "from@example.com", "to@example.com", req,
"Form\r\nBcc: victim@example.com", "contact")
s := string(b)
headers := s[:strings.Index(s, "\r\n\r\n")]
for line := range strings.SplitSeq(headers, "\r\n") {
if strings.HasPrefix(line, "Bcc:") {
t.Errorf("injected Bcc header survived composition:\n%s", s)
}
}
if n := strings.Count(headers, "Subject:"); n != 1 {
t.Errorf("expected exactly one Subject header in block %q, got %d", headers, n)
}
}
func TestRandomBoundary(t *testing.T) {
b1, err := randomBoundary()
if err != nil {
t.Fatalf("randomBoundary: %v", err)
}
b2, err := randomBoundary()
if err != nil {
t.Fatalf("randomBoundary: %v", err)
}
if !strings.HasPrefix(b1, "nuntius-") {
t.Errorf("boundary %q missing nuntius- prefix", b1)
}
if b1 == b2 {
t.Error("two consecutive boundaries should differ")
}
}
func TestSendMailDialError(t *testing.T) {
// Connect to a closed port to trigger a dial error quickly.
err := sendMail(config.SMTPConfig{Host: "127.0.0.1", Port: 1}, nil, "a@b.c", []string{"d@e.f"}, []byte("x"), 100*time.Millisecond, nil)
if err == nil {
t.Fatal("expected dial error")
}
}
func TestSendMailWithTLS(t *testing.T) {
cert := selfSignedCert(t)
addr := startFakeSMTP(t, cert)
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split host: %v", err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatalf("parse port: %v", err)
}
auth := smtp.PlainAuth("", "user", "pass", host)
msg := mustCompose(t, "from@example.com", "to@example.com", contactform.Request{
Name: "Test User",
Email: "test@example.com",
Message: "Hello, this is a test message.",
}, "test", "contact")
err = sendMail(config.SMTPConfig{Host: host, Port: port}, auth, "from@example.com", []string{"to@example.com"}, msg, 5*time.Second, &tls.Config{InsecureSkipVerify: true})
if err != nil {
t.Fatalf("sendMail: %v", err)
}
}
func TestFormSenderSend(t *testing.T) {
cert := selfSignedCert(t)
addr := startFakeSMTP(t, cert)
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split host: %v", err)
}
port := 0
for _, c := range portStr {
port = port*10 + int(c-'0')
}
form := &config.Form{
Name: "test",
Type: "contact",
From: "from@example.com",
To: "to@example.com",
SMTP: config.SMTPConfig{
Host: host,
Port: port,
User: "user",
Password: "pass",
},
}
s := NewFormSender(form)
s.TLSConfig = &tls.Config{InsecureSkipVerify: true}
err = s.Send(contactform.Request{
Name: "Test User",
Email: "test@example.com",
Message: "Hello, this is a test message.",
})
if err != nil {
t.Fatalf("Send: %v", err)
}
}
+765
View File
@@ -0,0 +1,765 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net/http"
"net/url"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
"sourcedock.dev/petrbalvin/nuntius/internal/email"
"sourcedock.dev/petrbalvin/nuntius/internal/storage"
"sourcedock.dev/petrbalvin/nuntius/internal/telegram"
)
const (
// secondsPerHour is the token-bucket refill window. It is part of the
// meaning of rate_limit_per_hour, not an independent policy: the
// bucket refills at perHour/3600 tokens per second.
secondsPerHour = 3600.0
)
// formSender is the interface for delivering a submission.
type formSender interface {
Send(req contactform.Request) error
}
// subscriberStorer persists newsletter subscribers.
// storage.NewsletterStore satisfies this interface.
type subscriberStorer interface {
Append(sub storage.Subscriber) error
}
// duplicateChecker lets the pipeline skip repeat newsletter subscriptions
// for an already recorded address. *storage.DedupeNewsletterStore satisfies
// it; stores without it keep their previous behaviour.
type duplicateChecker interface {
Has(email string) bool
}
// confirmationSender mails the double opt-in link to the subscriber.
// *email.FormSender satisfies it once its SMTP identity is configured.
type confirmationSender interface {
SendConfirmation(to, link string) error
}
// archiveStorer persists submissions for forms that asked for durability.
// *storage.ArchiveStore satisfies it.
type archiveStorer interface {
Append(sub storage.Submission) error
}
// acknowledgementSender mails the submitter a receipt. *email.FormSender
// satisfies it once its SMTP identity is configured.
type acknowledgementSender interface {
SendAcknowledgement(to string) error
}
// telegramNotifier delivers a submission summary to the owner's chat.
// *telegram.Notifier satisfies it.
type telegramNotifier interface {
Notify(formName string, req contactform.Request) error
}
// 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 {
trustProxy bool
dataDir string
// maxBodyBytes caps the request body size to prevent memory
// exhaustion; it comes from server.max_body_bytes.
maxBodyBytes int
// metricsToken guards GET /metrics; empty keeps the endpoint open.
metricsToken string
forms map[string]*config.Form
senders map[string]formSender
rateLimits map[string]*rateLimiter
stores map[string]subscriberStorer
archives map[string]archiveStorer
pendings map[string]*storage.PendingStore
notifiers map[string]telegramNotifier
stats *formStatsRegistry
closeOnce sync.Once
}
// New constructs a ContactHandler that serves all forms defined in cfg.
// The server-wide mechanics (body cap, rate limiter memory bounds) and the
// per-form policies (validation, pending lifetime) all come from cfg.
func New(cfg *config.Config) *ContactHandler {
h := &ContactHandler{
trustProxy: cfg.Server.TrustProxyHeaders,
dataDir: cfg.DataDir,
maxBodyBytes: cfg.Server.BodyLimit(),
metricsToken: cfg.Server.MetricsToken,
forms: make(map[string]*config.Form, len(cfg.Forms)),
senders: make(map[string]formSender, len(cfg.Forms)),
rateLimits: make(map[string]*rateLimiter, len(cfg.Forms)),
stores: make(map[string]subscriberStorer, len(cfg.Forms)),
archives: make(map[string]archiveStorer, len(cfg.Forms)),
pendings: make(map[string]*storage.PendingStore, len(cfg.Forms)),
notifiers: make(map[string]telegramNotifier, 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(limiterSettings{
perHour: f.RateLimit(),
maxBuckets: cfg.Server.MaxRateLimitBuckets(),
cleanupEvery: cfg.Server.RateLimitCleanup(),
maxBucketAge: cfg.Server.RateLimitMaxBucketAge(),
})
if f.Type == "newsletter" {
storePath := filepath.Join(cfg.DataDir, "newsletter-"+f.Name+".jsonl")
h.stores[f.Path] = storage.NewDedupeNewsletterStore(storage.NewNewsletterStore(storePath))
h.pendings[f.Path] = storage.NewPendingStore(
filepath.Join(cfg.DataDir, "newsletter-"+f.Name+"-pending.json"), f.PendingTTL())
} else if f.Archive {
h.archives[f.Path] = storage.NewArchiveStore(
filepath.Join(cfg.DataDir, "archive-"+f.Name+".jsonl"))
}
if f.Telegram != nil {
h.notifiers[f.Path] = telegram.New(
f.Telegram.BotToken, f.Telegram.ChatID, f.Telegram.Timeout())
}
}
// The registry needs the fully populated form map, hence after the loop.
h.stats = newFormStatsRegistry(h.forms)
h.restoreState()
return h
}
// Close stops the background cleanup goroutines for all rate limiters.
// It is idempotent: calling it more than once, from the shutdown path or
// a caller's cleanup, is safe.
func (h *ContactHandler) Close() {
h.closeOnce.Do(func() {
for _, lim := range h.rateLimits {
lim.stop()
}
})
}
// Register mounts one POST + OPTIONS handler per form, plus a single
// GET /health handler and a GET /metrics endpoint. Newsletter forms also
// get a GET <path>/confirm endpoint redeeming their opt-in tokens.
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))
if h.pendings[path] != nil {
mux.HandleFunc("GET "+path+"/confirm", h.makeConfirmHandler(path))
}
}
mux.HandleFunc("GET /health", h.Health)
mux.HandleFunc("GET /metrics", h.Metrics)
}
// 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 != "" {
h.bump(path, metricOriginBlocked)
respondError(w, http.StatusForbidden, "origin_not_allowed", "")
return
}
h.bump(path, metricReceived)
// Rate limit. An explicit rate_limit_per_hour = 0 disables it.
if form.RateLimit() > 0 {
ip := ClientIP(r, h.trustProxy)
if !limiter.allow(ip) {
h.bump(path, metricRateLimited)
respondError(w, http.StatusTooManyRequests, "rate_limited", "Too many requests, please try again later.")
return
}
}
// Parse the body. A plain HTML form post speaks urlencoded or
// multipart and carries the same fields as the JSON contract
// under fixed names; every other content type speaks JSON. The
// size cap applies to all shapes alike. The honeypot check is
// bound here as well, because the two shapes carry it
// differently: a field on the form, a key in the raw JSON.
r.Body = http.MaxBytesReader(w, r.Body, int64(h.maxBodyBytes))
var req contactform.Request
mediaType := mediaTypeOf(r.Header.Get("Content-Type"))
honeypotHit := func() bool { return false }
switch mediaType {
case "application/x-www-form-urlencoded", "multipart/form-data":
// 1 MiB of in-memory multipart is plenty: only the value
// parts are read, file parts are ignored, and the body cap
// bounds the whole request anyway.
if mediaType == "multipart/form-data" {
if err := r.ParseMultipartForm(1 << 20); err != nil {
h.parseFailed(w, path, err)
return
}
} else if err := r.ParseForm(); err != nil {
h.parseFailed(w, path, err)
return
}
req = requestFromForm(r.PostForm)
if hp := form.Honeypot(); hp != "" {
honeypotHit = func() bool { return r.PostForm.Get(hp) != "" }
}
default:
body, err := io.ReadAll(r.Body)
if err != nil {
h.parseFailed(w, path, err)
return
}
if err := json.Unmarshal(body, &req); err != nil {
h.bump(path, metricInvalidBody)
respondError(w, http.StatusBadRequest, "invalid_body", "Could not parse request body.")
return
}
if hp := form.Honeypot(); hp != "" {
honeypotHit = func() bool {
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return false
}
v, ok := raw[hp]
return ok && v != nil && v != ""
}
}
}
// Honeypot: silently accept but never send. An empty field name
// disables the check for this form.
if honeypotHit() {
h.bump(path, metricHoneypotBlocked)
slog.Info("honeypot triggered, dropping silently",
"form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondSuccess(w, r, form.RedirectURL)
return
}
// Validate against the form's configured policy.
if errs := contactform.Validate(&req, form.Policy()); len(errs) > 0 {
h.bump(path, metricValidationFailed)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
if err := json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: "validation",
Details: errs,
}); err != nil {
slog.Error("failed to encode json response", "err", err)
}
return
}
// Skip repeat newsletter subscriptions for an address that is
// already recorded: no second mail, no duplicate log line. The
// caller sees the same success response as first-timers.
if dup, ok := store.(duplicateChecker); ok && dup.Has(req.Email) {
h.bump(path, metricDuplicateSignup)
slog.Info("duplicate newsletter signup suppressed",
"form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondSuccess(w, r, form.RedirectURL)
return
}
// Newsletter forms run the double opt-in flow: record a pending
// subscription and mail the subscriber a confirmation link. The
// owner is notified only once the link is redeemed, so bots that
// fill the form cannot flood the inbox.
if form.Type == "newsletter" {
rawToken, err := storage.RandomToken()
if err != nil {
h.bump(path, metricSendFailed)
slog.Error("token generation failed",
"err", err, "form", form.Name, "path", path)
respondError(w, http.StatusInternalServerError, "send_failed", "Could not start the subscription.")
return
}
pend := storage.PendingSubscription{
Email: req.Email,
IP: ClientIP(r, h.trustProxy),
}
pending := h.pendings[path]
if pending == nil {
// unreachable via New(): production always builds one
slog.Error("newsletter form without a pending store", "path", path)
respondError(w, http.StatusInternalServerError, "storage_failed", "Could not start the subscription.")
return
}
if err := pending.Issue(rawToken, pend); err != nil {
h.bump(path, metricPersistFailed)
slog.Error("pending subscription store failed",
"err", err, "form", form.Name, "path", path)
respondError(w, http.StatusInternalServerError, "storage_failed", "Could not start the subscription.")
return
}
link := h.confirmLink(r, path, rawToken)
var sendErr error
if cs, ok := sender.(confirmationSender); ok {
sendErr = cs.SendConfirmation(req.Email, link)
} else {
sendErr = sender.Send(req)
}
if sendErr != nil {
h.bump(path, metricSendFailed)
slog.Error("confirmation mail failed",
"err", sendErr, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondError(w, http.StatusInternalServerError, "send_failed", "Could not send the confirmation email.")
return
}
h.bump(path, metricConfirmationSent)
slog.Info("confirmation mail sent",
"form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondSuccess(w, r, form.RedirectURL)
return
}
// Archive before sending: the point of the log is that a failed
// SMTP round-trip loses nothing. A failed append fails the
// request without sending, so a retry cannot split the mail
// from its record.
if archive := h.archives[path]; archive != nil {
if err := archive.Append(storage.Submission{
Form: form.Name,
Name: req.Name,
Email: req.Email,
Service: req.Service,
Message: req.Message,
IP: ClientIP(r, h.trustProxy),
}); err != nil {
h.bump(path, metricPersistFailed)
slog.Error("submission archive append failed",
"err", err, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
respondError(w, http.StatusInternalServerError, "storage_failed",
"Could not record submission.")
return
}
}
// Deliver. The owner mail is the record and the Telegram
// notification the bell: the submission counts as delivered
// when either channel gets through, and only when both fail
// (or no bell is configured) does the caller see an error.
sendErr := sender.Send(req)
if sendErr != nil {
h.bump(path, metricSendFailed)
slog.Error("send failed",
"err", sendErr, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
}
delivered := sendErr == nil
if notifier := h.notifiers[path]; notifier != nil {
if err := notifier.Notify(form.Name, req); err != nil {
h.bump(path, metricTelegramFailed)
slog.Error("telegram notification failed",
"err", err, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
} else {
delivered = true
if sendErr != nil {
slog.Warn("telegram delivered after the mail failed",
"form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
}
}
}
if !delivered {
respondError(w, http.StatusInternalServerError, "send_failed", "Could not send email.")
return
}
if sendErr == nil {
h.bump(path, metricSent)
slog.Info("message sent",
"form", form.Name, "path", path,
"service", req.Service, "ip", ClientIP(r, h.trustProxy),
)
}
// The optional receipt to the submitter is best-effort: the
// submission is delivered, so a failed acknowledgement must
// not turn an accepted submission into an error.
if form.AutoReply {
if as, ok := sender.(acknowledgementSender); ok {
if err := as.SendAcknowledgement(req.Email); err != nil {
h.bump(path, metricAutoReplyFailed)
slog.Warn("acknowledgement mail failed",
"err", err, "form", form.Name, "path", path, "ip", ClientIP(r, h.trustProxy))
}
}
}
respondSuccess(w, r, form.RedirectURL)
}
}
// confirmLink builds the absolute opt-in URL for a token. The scheme is
// https whenever a trusted proxy reports X-Forwarded-Proto=https, matching
// the ClientIP trust model.
func (h *ContactHandler) confirmLink(r *http.Request, path, rawToken string) string {
scheme := "http"
if h.trustProxy && r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return fmt.Sprintf("%s://%s%s/confirm?token=%s", scheme, r.Host, path, rawToken)
}
// makeConfirmHandler redeems a double opt-in token: the pending entry moves
// into the confirmed subscriber log and the owner is notified best-effort.
// The response is HTML because humans open these links in browsers.
func (h *ContactHandler) makeConfirmHandler(path string) http.HandlerFunc {
form := h.forms[path]
sender := h.senders[path]
store := h.stores[path]
pending := h.pendings[path]
return func(w http.ResponseWriter, r *http.Request) {
writePage := func(code int, title, detail string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
fmt.Fprintf(w, "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"+
"<title>nuntius</title></head><body style=\"font-family:sans-serif;text-align:center;padding-top:3rem\">"+
"<h1>%s</h1><p>%s</p></body></html>", title, detail)
}
rawToken := r.URL.Query().Get("token")
sub, ok := pending.Peek(rawToken)
if !ok {
h.bump(path, metricConfirmFailed)
writePage(http.StatusGone, "Link expired",
"This confirmation link is invalid or has expired. Please sign up again.")
return
}
// The main record lands before the pending entry is dropped, so a
// storage failure keeps the token redeemable and nothing is lost.
alreadyRecorded := false
if dup, ok := store.(duplicateChecker); ok && dup.Has(sub.Email) {
alreadyRecorded = true
}
if !alreadyRecorded {
if err := store.Append(storage.Subscriber{
Email: sub.Email,
IP: sub.IP,
Form: form.Name,
}); err != nil {
h.bump(path, metricPersistFailed)
slog.Error("confirmed subscription append failed",
"err", err, "form", form.Name, "path", path)
writePage(http.StatusInternalServerError, "Almost there",
"The confirmation could not be saved. Please try the link again shortly.")
return
}
}
pending.Consume(rawToken)
h.bump(path, metricConfirmed)
slog.Info("newsletter subscription confirmed",
"form", form.Name, "path", path)
// Owner notification is best-effort and must not affect the
// subscriber's result.
if err := sender.Send(contactform.Request{
Name: "(nuntius)",
Email: sub.Email,
Message: "The address above confirmed its newsletter subscription.",
}); err != nil {
h.bump(path, metricSendFailed)
slog.Error("owner notification failed",
"err", err, "form", form.Name, "path", path)
}
writePage(http.StatusOK, "Subscription confirmed",
"The address "+sub.Email+" is now subscribed to \""+form.Name+"\".")
}
}
// Health handles GET /health.
func (h *ContactHandler) Health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"forms": len(h.forms),
}); err != nil {
slog.Error("failed to encode health response", "err", err)
}
}
// --- internals ---
func formAllowed(form *config.Form, origin string) bool {
if origin == "" {
return true
}
return slices.Contains(form.AllowedOrigins, origin)
}
func writeCORS(w http.ResponseWriter, origin string, allowed []string) {
// Only echo the origin back if it is in the allowlist.
if slices.Contains(allowed, 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. Its memory mechanics (the cap on
// distinct buckets, the cleanup tick, the age at which an idle bucket is
// dropped) come from the server configuration, not from constants here.
type rateLimiter struct {
mu sync.Mutex
perHour int
maxBuckets int
cleanupEvery time.Duration
maxBucketAge time.Duration
buckets map[string]*bucket
stopCh chan struct{}
}
type bucket struct {
tokens float64
last time.Time
}
// limiterSettings carries the configured mechanics of one rate limiter.
type limiterSettings struct {
perHour int
maxBuckets int
cleanupEvery time.Duration
maxBucketAge time.Duration
}
func newRateLimiter(s limiterSettings) *rateLimiter {
r := &rateLimiter{
perHour: s.perHour,
maxBuckets: s.maxBuckets,
cleanupEvery: s.cleanupEvery,
maxBucketAge: s.maxBucketAge,
buckets: make(map[string]*bucket),
stopCh: make(chan struct{}),
}
// Clean up entries older than the configured bucket age on every tick.
r.startCleanup()
return r
}
// startCleanup launches a background goroutine that periodically removes
// expired bucket entries to prevent unbounded memory growth.
func (r *rateLimiter) startCleanup() {
go func() {
ticker := time.NewTicker(r.cleanupEvery)
defer ticker.Stop()
for {
select {
case <-ticker.C:
r.cleanup(r.maxBucketAge)
case <-r.stopCh:
return
}
}
}()
}
// stop terminates the cleanup goroutine.
func (r *rateLimiter) stop() {
close(r.stopCh)
}
// snapshot returns a copy of every live bucket so state can be written to
// disk without holding the lock while encoding.
func (r *rateLimiter) snapshot() map[string]bucket {
r.mu.Lock()
defer r.mu.Unlock()
out := make(map[string]bucket, len(r.buckets))
for ip, b := range r.buckets {
out[ip] = *b
}
return out
}
// restore merges persisted buckets, dropping entries older than the
// configured bucket age and stopping once the map cap is reached. Entries
// newer than the cutoff keep their remaining tokens.
func (r *rateLimiter) restore(entries map[string]bucket, now time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
for ip, b := range entries {
if len(r.buckets) >= r.maxBuckets {
return
}
cutoff := now.Add(-r.maxBucketAge)
if b.last.Before(cutoff) || b.last.After(now) {
continue
}
entry := b
entry.tokens = min(entry.tokens, float64(r.perHour))
r.buckets[ip] = &entry
}
}
func (r *rateLimiter) cleanup(maxAge time.Duration) {
r.mu.Lock()
defer r.mu.Unlock()
cutoff := time.Now().Add(-maxAge)
for ip, b := range r.buckets {
if b.last.Before(cutoff) {
delete(r.buckets, ip)
}
}
}
func (r *rateLimiter) allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
b, ok := r.buckets[ip]
if !ok {
if len(r.buckets) >= r.maxBuckets {
return false
}
b = &bucket{tokens: float64(r.perHour), last: now}
r.buckets[ip] = b
}
rate := float64(r.perHour) / secondsPerHour
elapsed := now.Sub(b.last).Seconds()
b.tokens = min(b.tokens+elapsed*rate, float64(r.perHour))
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func respondOK(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(contactform.Response{OK: true}); err != nil {
slog.Error("failed to encode ok response", "err", err)
}
}
// respondSuccess answers an accepted submission. A form with a
// redirect_url speaks browser: 303 See Other to the configured page, so a
// plain HTML form works without JavaScript and a bot hit is
// indistinguishable from a real one. The JSON contract is the default.
func respondSuccess(w http.ResponseWriter, r *http.Request, redirectURL string) {
if redirectURL != "" {
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
return
}
respondOK(w)
}
// parseFailed answers an unreadable, oversized or unparsable request body.
// An over-cap body is 413 regardless of the shape; everything else is a
// 400 invalid_body.
func (h *ContactHandler) parseFailed(w http.ResponseWriter, path string, err error) {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
h.bump(path, metricBodyTooLarge)
respondError(w, http.StatusRequestEntityTooLarge, "body_too_large", "Request body too large.")
return
}
h.bump(path, metricInvalidBody)
respondError(w, http.StatusBadRequest, "invalid_body", "Could not parse request body.")
}
// mediaTypeOf extracts the bare media type from a Content-Type header,
// lower-cased and without parameters.
func mediaTypeOf(header string) string {
mt, _, err := mime.ParseMediaType(header)
if err != nil {
return strings.ToLower(strings.TrimSpace(header))
}
return mt
}
// requestFromForm builds the request from posted form fields. The names
// are fixed for the plain HTML shape: name, email, service, message,
// plus the configured honeypot field, which the pipeline reads
// separately. File parts have no counterpart in the contract and are
// ignored.
func requestFromForm(v url.Values) contactform.Request {
return contactform.Request{
Name: v.Get("name"),
Email: v.Get("email"),
Service: v.Get("service"),
Message: v.Get("message"),
}
}
func respondError(w http.ResponseWriter, code int, err, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if encErr := json.NewEncoder(w).Encode(contactform.ErrorResponse{
Error: err,
Message: msg,
}); encErr != nil {
slog.Error("failed to encode error response", "err", encErr)
}
}
// ClientIP returns the address used for rate limiting and logging.
//
// With trustProxy false only the connection peer address is considered;
// it cannot be forged by the caller. With trustProxy true, headers set by
// a trusted reverse proxy take precedence: the first X-Forwarded-For
// entry, then X-Real-IP. Enable it only when such a proxy sits directly
// in front of nuntius and overwrites those headers rather than appending
// to them.
func ClientIP(r *http.Request, trustProxy bool) string {
if trustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if before, _, ok := strings.Cut(xff, ","); ok {
return strings.TrimSpace(before)
}
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
}
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package handler
import (
"crypto/subtle"
"encoding/json"
"log/slog"
"net/http"
"strings"
"sync"
"sourcedock.dev/petrbalvin/nuntius/internal/config"
)
// Canonical metric names. The same identifiers serve as counter keys in
// bump() and as JSON field names in GET /metrics responses.
const (
metricReceived = "received"
metricHoneypotBlocked = "honeypot_blocked"
metricRateLimited = "rate_limited"
metricOriginBlocked = "origin_blocked"
metricBodyTooLarge = "body_too_large"
metricInvalidBody = "invalid_body"
metricValidationFailed = "validation_failed"
metricSendFailed = "send_failed"
metricPersistFailed = "persist_failed"
metricDuplicateSignup = "duplicate_signup"
metricConfirmationSent = "confirmation_sent"
metricConfirmed = "confirmed"
metricConfirmFailed = "confirmation_failed"
metricSent = "sent"
metricAutoReplyFailed = "auto_reply_failed"
metricTelegramFailed = "telegram_failed"
)
// metricNames lists every counter in stable order so totals and snapshots
// cannot drift from the struct fields.
var metricNames = []string{
metricReceived,
metricHoneypotBlocked,
metricRateLimited,
metricOriginBlocked,
metricBodyTooLarge,
metricInvalidBody,
metricValidationFailed,
metricSendFailed,
metricPersistFailed,
metricDuplicateSignup,
metricConfirmationSent,
metricConfirmed,
metricConfirmFailed,
metricSent,
metricAutoReplyFailed,
metricTelegramFailed,
}
// FormStats holds lifetime counters for one form. Every field counts
// outcomes of requests routed to that form's endpoints.
type FormStats struct {
Received int64 `json:"received"`
HoneypotBlocked int64 `json:"honeypot_blocked"`
RateLimited int64 `json:"rate_limited"`
OriginBlocked int64 `json:"origin_blocked"`
BodyTooLarge int64 `json:"body_too_large"`
InvalidBody int64 `json:"invalid_body"`
ValidationFailed int64 `json:"validation_failed"`
SendFailed int64 `json:"send_failed"`
PersistFailed int64 `json:"persist_failed"`
DuplicateSignup int64 `json:"duplicate_signup"`
ConfirmationSent int64 `json:"confirmation_sent"`
Confirmed int64 `json:"confirmed"`
ConfirmFailed int64 `json:"confirmation_failed"`
Sent int64 `json:"sent"`
AutoReplyFailed int64 `json:"auto_reply_failed"`
TelegramFailed int64 `json:"telegram_failed"`
}
// incByIndex increments the counter at metricNames[i]; indexes outside the
// known set are ignored.
func (f *FormStats) incByIndex(i int) {
switch i {
case 0:
f.Received++
case 1:
f.HoneypotBlocked++
case 2:
f.RateLimited++
case 3:
f.OriginBlocked++
case 4:
f.BodyTooLarge++
case 5:
f.InvalidBody++
case 6:
f.ValidationFailed++
case 7:
f.SendFailed++
case 8:
f.PersistFailed++
case 9:
f.DuplicateSignup++
case 10:
f.ConfirmationSent++
case 11:
f.Confirmed++
case 12:
f.ConfirmFailed++
case 13:
f.Sent++
case 14:
f.AutoReplyFailed++
case 15:
f.TelegramFailed++
}
}
// add sums another snapshot into f.
func (f *FormStats) add(other FormStats) {
for i := range metricNames {
switch i {
case 0:
f.Received += other.Received
case 1:
f.HoneypotBlocked += other.HoneypotBlocked
case 2:
f.RateLimited += other.RateLimited
case 3:
f.OriginBlocked += other.OriginBlocked
case 4:
f.BodyTooLarge += other.BodyTooLarge
case 5:
f.InvalidBody += other.InvalidBody
case 6:
f.ValidationFailed += other.ValidationFailed
case 7:
f.SendFailed += other.SendFailed
case 8:
f.PersistFailed += other.PersistFailed
case 9:
f.DuplicateSignup += other.DuplicateSignup
case 10:
f.ConfirmationSent += other.ConfirmationSent
case 11:
f.Confirmed += other.Confirmed
case 12:
f.ConfirmFailed += other.ConfirmFailed
case 13:
f.Sent += other.Sent
case 14:
f.AutoReplyFailed += other.AutoReplyFailed
case 15:
f.TelegramFailed += other.TelegramFailed
}
}
}
// formStatsRegistry guards the per-form counters shared between request
// goroutines and the /metrics endpoint.
type formStatsRegistry struct {
mu sync.Mutex
stats map[string]*FormStats
}
func newFormStatsRegistry(forms map[string]*config.Form) *formStatsRegistry {
r := &formStatsRegistry{stats: make(map[string]*FormStats, len(forms))}
for path := range forms {
r.stats[path] = &FormStats{}
}
return r
}
// bump increments the named counter for a form. Unknown paths or metrics
// are dropped silently so logging can never fail a request.
func (r *formStatsRegistry) bump(path, metric string) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
fs, ok := r.stats[path]
if !ok {
return
}
for i, name := range metricNames {
if name == metric {
fs.incByIndex(i)
return
}
}
}
// snapshot returns a copy of every form's counters plus their sum.
func (r *formStatsRegistry) snapshot() (map[string]FormStats, FormStats) {
out := make(map[string]FormStats, len(r.stats))
var total FormStats
r.mu.Lock()
defer r.mu.Unlock()
for path, fs := range r.stats {
out[path] = *fs
total.add(*fs)
}
return out, total
}
// Metrics serves GET /metrics: lifetime counters per form and combined
// totals, as JSON. The endpoint is exempt from rate limiting and sends no
// CORS headers, so third-party pages cannot read submission volumes. With
// server.metrics_token set it requires that token as a bearer credential.
func (h *ContactHandler) Metrics(w http.ResponseWriter, r *http.Request) {
if h.metricsToken != "" {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(token), []byte(h.metricsToken)) != 1 {
w.Header().Set("WWW-Authenticate", `Bearer realm="nuntius metrics"`)
respondError(w, http.StatusUnauthorized, "unauthorized", "A valid bearer token is required.")
return
}
}
if h.stats == nil {
w.WriteHeader(http.StatusNotFound)
return
}
formSnapshots, total := h.stats.snapshot()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(map[string]any{
"totals": total,
"forms": formSnapshots,
}); err != nil {
slog.Error("failed to encode metrics response", "err", err)
}
}
// bump records one occurrence of metric for the given form path.
func (h *ContactHandler) bump(path, metric string) {
h.stats.bump(path, metric)
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package handler
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"time"
)
// rateLimitSnapshot is the on-disk shape of persisted limiter buckets.
// Schema lets future formats be detected instead of misparsed.
type rateLimitSnapshot struct {
Schema int `json:"schema"`
Saved time.Time `json:"saved"`
Forms map[string]map[string]persistedBucket `json:"forms"`
}
// persistedBucket is the exported wire form of an internal bucket.
type persistedBucket struct {
Tokens float64 `json:"tokens"`
Last time.Time `json:"last"`
}
const snapshotSchema = 1
// statePath returns the snapshot file location under the data directory.
func (h *ContactHandler) statePath() string {
return filepath.Join(h.dataDir, "ratelimit-snapshot.json")
}
// PersistState writes every rate-limit bucket to a snapshot file next to
// the newsletter logs. The write is atomic (temp file plus rename) and
// best-effort: a failure is logged, never fatal, because losing buckets
// only resets limits to their startup defaults. Call it during shutdown,
// before Close.
func (h *ContactHandler) PersistState() {
if h.dataDir == "" {
return
}
snap := rateLimitSnapshot{
Schema: snapshotSchema,
Saved: time.Now().UTC(),
Forms: make(map[string]map[string]persistedBucket, len(h.rateLimits)),
}
for path, lim := range h.rateLimits {
live := lim.snapshot()
wire := make(map[string]persistedBucket, len(live))
for ip, b := range live {
wire[ip] = persistedBucket{Tokens: b.tokens, Last: b.last}
}
snap.Forms[path] = wire
}
line, err := json.Marshal(snap)
if err != nil {
slog.Warn("rate limit snapshot marshal failed", "err", err)
return
}
dir := filepath.Dir(h.statePath())
if err := os.MkdirAll(dir, 0o755); err != nil {
slog.Warn("rate limit snapshot mkdir failed", "dir", dir, "err", err)
return
}
tmp := h.statePath() + ".tmp"
if err := os.WriteFile(tmp, line, 0o600); err != nil {
slog.Warn("rate limit snapshot write failed", "err", err)
return
}
if err := os.Rename(tmp, h.statePath()); err != nil {
slog.Warn("rate limit snapshot rename failed", "err", err)
}
}
// restoreState loads the previous snapshot, if any, back into the fresh
// rate limiters. Each limiter drops entries older than its configured
// bucket age; corrupt files are ignored with a warning, and nothing here
// is fatal: a missing or broken snapshot behaves like an empty one.
func (h *ContactHandler) restoreState() {
if h.dataDir == "" {
return
}
raw, err := os.ReadFile(h.statePath())
if err != nil {
return // no snapshot yet: the common first-start path
}
var snap rateLimitSnapshot
if err := json.Unmarshal(raw, &snap); err != nil {
slog.Warn("ignoring corrupt rate limit snapshot", "path", h.statePath(), "err", err)
return
}
if snap.Schema != snapshotSchema {
slog.Warn("ignoring rate limit snapshot with unknown schema",
"path", h.statePath(), "schema", snap.Schema)
return
}
now := time.Now()
restoredForms := 0
for path, wire := range snap.Forms {
lim, ok := h.rateLimits[path]
if !ok {
continue // form removed from config since the snapshot
}
entries := make(map[string]bucket, len(wire))
for ip, pb := range wire {
entries[ip] = bucket{tokens: pb.Tokens, last: pb.Last}
}
lim.restore(entries, now)
restoredForms++
}
slog.Info("restored rate limit buckets", "file", h.statePath(), "forms", restoredForms)
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//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"
"strings"
"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()
return appendLine(s.path, line)
}
// 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
}
// DedupeNewsletterStore wraps a NewsletterStore with an in-memory index of
// recorded addresses so callers can detect a repeat subscription before
// doing any user-visible work. The index is built lazily from the log on
// first use and kept in sync on successful appends.
//
// Addresses are compared case-insensitively. Strictly speaking the local
// part of an address may be case-sensitive, but every major provider treats
// mailbox names that way in practice, and bot submissions exploit
// exact-case variants to multiply signups.
type DedupeNewsletterStore struct {
store *NewsletterStore
mu sync.Mutex
seen map[string]struct{}
once sync.Once
}
// NewDedupeNewsletterStore wraps store.
func NewDedupeNewsletterStore(store *NewsletterStore) *DedupeNewsletterStore {
return &DedupeNewsletterStore{store: store}
}
// Path returns the wrapped store's file path.
func (d *DedupeNewsletterStore) Path() string {
return d.store.Path()
}
// Count returns the number of valid records in the log.
func (d *DedupeNewsletterStore) Count() (int, error) {
return d.store.Count()
}
// List returns all valid subscribers in insertion order.
func (d *DedupeNewsletterStore) List() ([]Subscriber, error) {
return d.store.List()
}
// seenKey normalises an address for comparison.
func seenKey(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
// load populates the index once per process lifetime. An unreadable log
// behaves like an empty index: dedupe then only covers this run, which
// matches how the process would behave after a hard crash anyway.
func (d *DedupeNewsletterStore) load() {
d.once.Do(func() {
d.seen = make(map[string]struct{})
subs, err := d.store.List()
if err != nil {
return
}
for _, sub := range subs {
d.seen[seenKey(sub.Email)] = struct{}{}
}
})
}
// Has reports whether the address was already recorded.
func (d *DedupeNewsletterStore) Has(email string) bool {
d.load()
d.mu.Lock()
defer d.mu.Unlock()
_, ok := d.seen[seenKey(email)]
return ok
}
// Remember records an address after its append succeeded.
func (d *DedupeNewsletterStore) Remember(sub Subscriber) {
d.load()
d.mu.Lock()
defer d.mu.Unlock()
d.seen[seenKey(sub.Email)] = struct{}{}
}
// Append persists sub and records the address on success.
func (d *DedupeNewsletterStore) Append(sub Subscriber) error {
if err := d.store.Append(sub); err != nil {
return err
}
d.Remember(sub)
return nil
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//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)
}
}
// The dedupe wrapper records delivered addresses and reports repeats
// case-insensitively; underlying Append semantics stay untouched.
func TestDedupeNewsletterStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "subs.jsonl")
store := NewNewsletterStore(path)
dedupe := NewDedupeNewsletterStore(store)
if dedupe.Has("Jane@Example.com") {
t.Fatal("fresh log should not contain the address")
}
if err := dedupe.Append(Subscriber{Email: "jane@example.com"}); err != nil {
t.Fatalf("append: %v", err)
}
if !dedupe.Has(" Jane@Example.COM ") {
t.Error("address should be found after append, ignoring case and spaces")
}
n, err := store.Count()
if err != nil || n != 1 {
t.Fatalf("log lines = %d (err %v), want exactly one record", n, err)
}
}
+185
View File
@@ -0,0 +1,185 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Double opt-in support: addresses wait in a pending file until their
// confirmation token is redeemed, then move into the main subscriber log.
package storage
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// PendingTTL is how long an unconfirmed subscription stays actionable.
// After that it is purged and the address must be signed up again.
const PendingTTL = 72 * time.Hour
// RandomToken returns a 32-byte cryptographically random value hex-encoded
// for use inside URLs. Only its SHA-256 hash is persisted; the raw value
// lives exclusively in the confirmation link.
func RandomToken() (string, error) {
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", fmt.Errorf("generate confirmation token: %w", err)
}
return hex.EncodeToString(buf[:]), nil
}
type PendingSubscription struct {
Email string `json:"email"`
IP string `json:"ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type pendingFile struct {
Schema int `json:"schema"`
Entries map[string]PendingSubscription `json:"entries"`
}
const pendingSchema = 1
// PendingStore keeps unconfirmed newsletter subscriptions keyed by the hash
// of their confirmation token. The whole set is rewritten atomically on
// every change: pending files stay tiny (only signups within the TTL), so
// the append-only trick is not needed here.
type PendingStore struct {
mu sync.Mutex
path string
ttl time.Duration
}
// NewPendingStore wraps path with the given entry lifetime.
func NewPendingStore(path string, ttl time.Duration) *PendingStore {
if ttl <= 0 {
ttl = PendingTTL
}
return &PendingStore{path: path, ttl: ttl}
}
// Path returns the backing file location.
func (p *PendingStore) Path() string { return p.path }
// TTL returns the entry lifetime the store enforces.
func (p *PendingStore) TTL() time.Duration { return p.ttl }
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// load reads the file, prunes expired entries and persists the pruned set.
// A missing or corrupt file behaves like an empty store.
func (p *PendingStore) load(now time.Time) (map[string]PendingSubscription, error) {
f := pendingFile{Schema: pendingSchema, Entries: map[string]PendingSubscription{}}
raw, err := os.ReadFile(p.path)
switch {
case err == nil:
if err := json.Unmarshal(raw, &f); err != nil || f.Schema != pendingSchema {
return f.Entries, fmt.Errorf("unreadable pending store %s", p.path)
}
case os.IsNotExist(err):
default:
return f.Entries, fmt.Errorf("read pending store %s: %w", p.path, err)
}
dirty := false
for k, e := range f.Entries {
if now.Sub(e.CreatedAt) > p.ttl || e.CreatedAt.After(now.Add(time.Hour)) {
delete(f.Entries, k)
dirty = true
}
}
if dirty {
if werr := p.save(f); werr != nil {
return f.Entries, werr
}
}
return f.Entries, nil
}
func (p *PendingStore) save(f pendingFile) error {
line, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("marshal pending store: %w", err)
}
if err := os.MkdirAll(filepath.Dir(p.path), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(p.path), err)
}
tmp := p.path + ".tmp"
if err := os.WriteFile(tmp, line, 0o600); err != nil {
return fmt.Errorf("write %s: %w", tmp, err)
}
return os.Rename(tmp, p.path)
}
// Issue stores a new pending subscription keyed by the token hash,
// superseding any earlier entry for the same address. A load warning
// (unreadable file) is non-fatal: the new entry is still written.
func (p *PendingStore) Issue(rawToken string, sub PendingSubscription) error {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
for k, e := range entries {
if seenKey(e.Email) == seenKey(sub.Email) && k != hashToken(rawToken) {
delete(entries, k) // one live token per address
}
}
sub.CreatedAt = now.UTC()
entries[hashToken(rawToken)] = sub
return p.save(pendingFile{Schema: pendingSchema, Entries: entries})
}
// Consume redeems a token: a valid, unexpired entry is removed from the
// file and returned. Unknown tokens, already-redeemed tokens and expired
// entries all report false.
func (p *PendingStore) Consume(rawToken string) (PendingSubscription, bool) {
p.mu.Lock()
defer p.mu.Unlock()
key := hashToken(rawToken)
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[key]
if !ok {
return PendingSubscription{}, false
}
delete(entries, key)
_ = p.save(pendingFile{Schema: pendingSchema, Entries: entries})
if now.Sub(sub.CreatedAt) > p.ttl {
return PendingSubscription{}, false
}
return sub, true
}
// Peek returns the subscription behind rawToken without consuming it,
// reporting false when the token is unknown or expired.
func (p *PendingStore) Peek(rawToken string) (PendingSubscription, bool) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[hashToken(rawToken)]
if !ok || now.Sub(sub.CreatedAt) > p.ttl {
return PendingSubscription{}, false
}
return sub, true
}
// HasToken reports whether raw is still a live, redeemable token.
func (p *PendingStore) HasToken(rawToken string) bool {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
entries, _ := p.load(now)
sub, ok := entries[hashToken(rawToken)]
return ok && now.Sub(sub.CreatedAt) <= p.ttl
}
+91
View File
@@ -0,0 +1,91 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestPendingStoreRoundTrip(t *testing.T) {
p := NewPendingStore(filepath.Join(t.TempDir(), "pending.json"), 72*time.Hour)
if _, ok := p.Consume("unknown-token"); ok {
t.Fatal("unknown token must not consume")
}
if err := p.Issue("token-a", PendingSubscription{Email: "Jane@Example.COM", IP: "203.0.113.9"}); err != nil {
t.Fatalf("issue: %v", err)
}
sub, ok := p.Consume("token-a")
if !ok {
t.Fatal("valid token must consume")
}
if sub.Email != "Jane@Example.COM" || sub.IP != "203.0.113.9" {
t.Errorf("consumed entry = %+v", sub)
}
if _, ok := p.Consume("token-a"); ok {
t.Error("token must be single-use")
}
}
// An expired entry is never returned and is purged from the file.
func TestPendingStoreExpiry(t *testing.T) {
path := filepath.Join(t.TempDir(), "pending.json")
stale, _ := json.Marshal(pendingFile{Schema: pendingSchema, Entries: map[string]PendingSubscription{
hashToken("old"): {Email: "a@b.c", CreatedAt: time.Now().Add(-96 * time.Hour)},
}})
if err := os.WriteFile(path, stale, 0o600); err != nil {
t.Fatal(err)
}
if _, ok := NewPendingStore(path, 72*time.Hour).Consume("old"); ok {
t.Fatal("expired token must not consume")
}
raw, _ := os.ReadFile(path)
if strings.Contains(string(raw), `"a@b.c"`) {
t.Error("expired entry was not purged from the file")
}
}
// Issue replaces the previous live token for the same address.
func TestPendingStoreRotation(t *testing.T) {
path := filepath.Join(t.TempDir(), "pending.json")
p := NewPendingStore(path, 72*time.Hour)
if err := p.Issue("first", PendingSubscription{Email: "jane@example.com"}); err != nil {
t.Fatal(err)
}
if err := p.Issue("second", PendingSubscription{Email: "jane@example.com"}); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(path)
if strings.Count(string(raw), "@") != 1 {
t.Errorf("expected a single live entry after rotation, got %s", raw)
}
if _, ok := p.Consume("first"); ok {
t.Error("superseded token must no longer work")
}
if _, ok := p.Consume("second"); !ok {
t.Error("current token must still work")
}
}
func TestRandomTokenIsHexAndUnique(t *testing.T) {
a, err := RandomToken()
if err != nil {
t.Fatal(err)
}
b, err := RandomToken()
if err != nil {
t.Fatal(err)
}
if len(a) != 64 || a == b {
t.Errorf("tokens = %q %q, want unique 64-char hex", a, b)
}
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Submission is one archived form submission, one JSON object per line.
// Newsletter forms do not write here: they persist through the double
// opt-in subscriber log instead.
type Submission struct {
Form string `json:"form"`
Name string `json:"name,omitempty"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
IP string `json:"ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ArchiveStore is a file-based append-only log of submissions. All methods
// are safe for concurrent use. The file and its parent directory are
// created lazily on first Append, so a form that archives nothing writes
// nothing.
type ArchiveStore struct {
path string
mu sync.Mutex
}
// NewArchiveStore returns a store that appends to path.
func NewArchiveStore(path string) *ArchiveStore {
return &ArchiveStore{path: path}
}
// Path returns the file path this store writes to.
func (s *ArchiveStore) Path() string {
return s.path
}
// Append writes sub as a single JSON line to the log.
func (s *ArchiveStore) Append(sub Submission) error {
if sub.CreatedAt.IsZero() {
sub.CreatedAt = time.Now().UTC()
}
line, err := json.Marshal(sub)
if err != nil {
return fmt.Errorf("marshal submission: %w", err)
}
line = append(line, '\n')
s.mu.Lock()
defer s.mu.Unlock()
return appendLine(s.path, line)
}
// appendLine writes one marshalled JSON line to an append-only log,
// creating the file and its parent directory on first use. The caller
// holds the store's lock.
func appendLine(path string, line []byte) (err error) {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("close %s: %w", path, cerr)
}
}()
if _, err := f.Write(line); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package storage
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestArchiveStoreRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "archive-test.jsonl")
store := NewArchiveStore(path)
first := Submission{
Form: "contact",
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss an engagement.",
IP: "192.0.2.1",
}
if err := store.Append(first); err != nil {
t.Fatalf("append: %v", err)
}
second := Submission{Form: "feedback", Email: "other@example.com", Message: "Short"}
if err := store.Append(second); err != nil {
t.Fatalf("append: %v", err)
}
f, err := os.Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
var got []Submission
scanner := bufio.NewScanner(f)
for scanner.Scan() {
var sub Submission
if err := json.Unmarshal(scanner.Bytes(), &sub); err != nil {
t.Fatalf("unmarshal line: %v", err)
}
got = append(got, sub)
}
if err := scanner.Err(); err != nil {
t.Fatalf("scan: %v", err)
}
if len(got) != 2 {
t.Fatalf("lines = %d, want 2", len(got))
}
if got[0].CreatedAt.IsZero() {
t.Errorf("first created_at is zero, want a stamped time")
}
first.CreatedAt = got[0].CreatedAt
if got[0] != first {
t.Errorf("first = %+v, want %+v", got[0], first)
}
if got[1].CreatedAt.IsZero() {
t.Errorf("second created_at is zero, want a stamped time")
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package telegram delivers form submission summaries to a Telegram chat
// through the Bot API. One-way by design: nuntius posts a message and
// never reads anything back, so there are no conversations, commands or
// callbacks here, and no bot platform either.
package telegram
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
// Notifier posts submission summaries to one chat through one bot.
type Notifier struct {
botToken string
chatID string
timeout time.Duration
// apiURL is the Bot API origin; tests point it at a fake server.
apiURL string
}
// New returns a Notifier posting as the bot into the chat.
func New(botToken, chatID string, timeout time.Duration) *Notifier {
return &Notifier{
botToken: botToken,
chatID: chatID,
timeout: timeout,
apiURL: "https://api.telegram.org",
}
}
// Notify posts one submission summary to the chat. Plain text on purpose:
// a parse mode would turn submitted content into markup that has to be
// escaped, and plain text cannot be injected.
func (n *Notifier) Notify(formName string, req contactform.Request) error {
body, err := json.Marshal(map[string]any{
"chat_id": n.chatID,
"text": summary(formName, req),
"disable_web_page_preview": true,
})
if err != nil {
return fmt.Errorf("marshal telegram payload: %w", err)
}
httpReq, err := http.NewRequest(http.MethodPost,
n.apiURL+"/bot"+n.botToken+"/sendMessage", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build telegram request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: n.timeout}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("telegram call: %s", redact(err.Error(), n.botToken))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram sendMessage: HTTP %d", resp.StatusCode)
}
// The API answers {"ok":false,"description":...} on refusal, so the
// body decides, not the status code alone.
var payload struct {
OK bool `json:"ok"`
Description string `json:"description"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
return fmt.Errorf("telegram response: %w", err)
}
if !payload.OK {
return fmt.Errorf("telegram sendMessage: %s", payload.Description)
}
return nil
}
// summary renders the plain-text message posted to the chat.
func summary(formName string, req contactform.Request) string {
var b strings.Builder
fmt.Fprintf(&b, "New message on %s\n", formName)
fmt.Fprintf(&b, "From: %s <%s>\n", req.Name, req.Email)
if req.Service != "" {
fmt.Fprintf(&b, "Service interest: %s\n", req.Service)
}
b.WriteString("\n")
b.WriteString(req.Message)
b.WriteString("\n")
return b.String()
}
// redact keeps the bot token out of error text: an URL error carries the
// full request URL, token included, and credentials never reach a log.
func redact(s, secret string) string {
if secret == "" {
return s
}
return strings.ReplaceAll(s, secret, "[redacted]")
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package telegram
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
)
var testRequest = contactform.Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hello, I would like to discuss an engagement.",
}
// apiCall carries what the fake API saw; the channel establishes the
// happens-before edge the HTTP response alone does not.
type apiCall struct {
req *http.Request
body string
}
// fakeAPI answers with the given payload and records the request; the
// returned function waits for the call and yields it.
func fakeAPI(t *testing.T, status int, payload string) (*Notifier, func() apiCall) {
t.Helper()
calls := make(chan apiCall, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
calls <- apiCall{req: r, body: string(b)}
w.WriteHeader(status)
_, _ = w.Write([]byte(payload))
}))
t.Cleanup(srv.Close)
n := New("123:secret", "-100200300", 5*time.Second)
n.apiURL = srv.URL
return n, func() apiCall {
select {
case c := <-calls:
return c
case <-time.After(5 * time.Second):
t.Fatal("the fake API never saw the request")
return apiCall{}
}
}
}
func TestNotifyPostsTheSummary(t *testing.T) {
n, call := fakeAPI(t, http.StatusOK, `{"ok":true}`)
if err := n.Notify("contact", testRequest); err != nil {
t.Fatalf("notify: %v", err)
}
got := call()
if p := got.req.URL.Path; p != "/bot123:secret/sendMessage" {
t.Errorf("path = %q, want the bot token and sendMessage", p)
}
var payload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(got.body), &payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.ChatID != "-100200300" {
t.Errorf("chat_id = %q, want the configured chat", payload.ChatID)
}
for _, want := range []string{
"New message on contact",
"From: Jane Doe <jane@example.com>",
"Service interest: architecture",
"Hello, I would like to discuss an engagement.",
} {
if !strings.Contains(payload.Text, want) {
t.Errorf("summary missing %q", want)
}
}
}
func TestNotifyReportsAPIRefusal(t *testing.T) {
// The API refuses with a non-200 status and an ok:false body; both
// shapes must surface as an error.
n, _ := fakeAPI(t, http.StatusUnauthorized, `{"ok":false,"description":"Unauthorized"}`)
if err := n.Notify("contact", testRequest); err == nil {
t.Errorf("notify over HTTP 401 succeeded, want an error")
}
n, _ = fakeAPI(t, http.StatusOK, `{"ok":false,"description":"chat not found"}`)
if err := n.Notify("contact", testRequest); err == nil {
t.Errorf("notify over an ok:false body succeeded, want an error")
}
}
// TestNotifyRedactsTheToken pins the credential rule: the token never
// reaches an error string, and error text is what the log carries.
func TestNotifyRedactsTheToken(t *testing.T) {
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// A connection reset surfaces as an URL error carrying the full
// request URL, token included.
srv.CloseClientConnections()
}))
t.Cleanup(srv.Close)
n := New("123:secret", "-100200300", 5*time.Second)
n.apiURL = srv.URL
err := n.Notify("contact", testRequest)
if err == nil {
t.Fatalf("notify over a killed connection succeeded, want an error")
}
if strings.Contains(err.Error(), "123:secret") {
t.Errorf("error text carries the bot token: %q", err.Error())
}
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
// Package version exposes the nuntius release identity.
package version
import "runtime/debug"
// Name is the program name reported by `--version` and in log lines.
const Name = "nuntius"
// Version reports the release the toolchain recorded for this build: the
// tag when the checkout is at one, a pseudo-version naming the commit
// otherwise, and (devel) outside version control. Nothing writes a version
// number, so the recorded value cannot go stale, and a dirty tree reports
// +dirty honestly.
func Version() string {
bi, ok := debug.ReadBuildInfo()
if !ok || bi.Main.Version == "" {
return "(devel)"
}
return bi.Main.Version
}
+22
View File
@@ -0,0 +1,22 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
//go:build linux || freebsd
package version
import "testing"
func TestName(t *testing.T) {
if Name != "nuntius" {
t.Errorf("Name = %q, want %q", Name, "nuntius")
}
}
func TestVersion(t *testing.T) {
// The recorded version is never empty: at a tag it is the tag, elsewhere
// a pseudo-version, and (devel) outside version control.
if Version() == "" {
t.Error("Version must not be empty")
}
}
+105
View File
@@ -0,0 +1,105 @@
# nuntius.
#
# Everything below the variable block is the standard recipe set from the `justfile`
# skill, identical in every repository; project values live in the variable block only.
binary := "nuntius"
package := "./cmd/server"
# What the test and bench recipes sweep. Scoped to the logic packages; the
# thin cmd/ would drag the coverage total under the 80 percent floor. Keep
# it equal to the Tests step in .gitea/workflows/test.yml.
packages := "./internal/..."
# The memory fence for the test recipes: a cgroup ceiling with swap off, so a
# runaway run dies as a failed run and never eats the machine. 4G is the
# default; the suite is small and quiet, so no raise is warranted.
memlimit := "4G"
bindir := env_var_or_default("BINDIR", env_var("HOME") / ".local" / "bin")
default:
@just --list
# Compile. Zero errors, zero warnings; -trimpath and -buildvcs make the binary place-independent and version-stamped.
build:
CGO_ENABLED=0 go build -trimpath -buildvcs=true -ldflags "-s -w" -o bin/{{binary}} {{package}}
# The test gate: the suite, no cache, the coverage floor, under the memory fence.
test:
#!/usr/bin/env perl
my @fence = (q{systemd-run}, q{--user}, q{--scope},
q{-p}, q{MemoryMax={{memlimit}}}, q{-p}, q{MemorySwapMax=0});
system(@fence, q{go}, q{test}, q{-count=1}, q{-timeout}, q{10m},
q{-coverprofile}, q{coverage.out}, qw({{packages}})) == 0
or die qq{the test suite failed\n};
open(my $c, q{-|}, q{go}, q{tool}, q{cover}, q{-func=coverage.out}) or die qq{cover: $!};
my $total;
while (my $l = <$c>) { $total = $1 if $l =~ m{^total:\s+\S+\s+([0-9.]+)%} }
close($c);
die qq{no total line in coverage.out\n} unless defined $total;
printf qq{Total coverage: %s%%\n}, $total;
exit($total < 80 ? 1 : 0);
# The same suite under the race detector. The expensive one, still fenced.
race:
systemd-run --user --scope -p MemoryMax={{memlimit}} -p MemorySwapMax=0 go test -race -count=1 -timeout 10m {{packages}}
# Fast scoped run for iterating. This is the one that runs after every edit.
unit pkgs=packages run=".*":
systemd-run --user --scope -p MemoryMax={{memlimit}} -p MemorySwapMax=0 go test {{pkgs}} -run '{{run}}'
# Time-boxed fuzz of one target in one package. The package is required; never a gate.
fuzz target pkg fuzztime="60s":
systemd-run --user --scope -p MemoryMax={{memlimit}} -p MemorySwapMax=0 go test -run '^$' -fuzz '{{target}}' -fuzztime={{fuzztime}} {{pkg}}
# A ceiling would distort the measurement, and the recipe's own rule is that nothing else runs while it does.
# Benchmarks. On an idle machine only, and deliberately unfenced.
bench pkgs=packages:
go test -run '^$' -bench=. -benchmem -count=5 {{pkgs}}
# Format in place.
fmt:
gofmt -w .
# Zero diff. Prints nothing when everything is formatted.
fmt-check:
#!/usr/bin/env perl
open(my $g, q{-|}, q{gofmt}, q{-l}, q{.}) or die qq{gofmt: $!};
my @bad = <$g>;
close($g);
print @bad;
exit(@bad ? 1 : 0);
# Both static gates: go vet and go fix -diff.
vet:
go vet ./...
go fix -diff ./...
# The definition of done, in one command. Once per task, never per edit.
gates: build fmt-check vet test race
# Build artefacts only, not the installed binary.
clean:
rm -rf bin/ coverage.out
# Build, then copy the binary into bindir.
install: build
install -d "{{bindir}}"
install -m 755 bin/{{binary}} "{{bindir}}/{{binary}}"
# Remove the installed binary.
uninstall:
rm -f "{{bindir}}/{{binary}}"
# Run the program. The flag is there because `go run` does not stamp the build otherwise.
run:
go run -buildvcs=true {{package}}
# Run with watch or hot reload, where the project has one.
dev:
go run -buildvcs=true {{package}}
# Coverage report as an HTML map from the gate's profile; not standard because the gate needs only the numeric floor, and a browser artefact is exploration, not a gate.
coverage-html: test
go tool cover -html=coverage.out -o coverage.html
+91
View File
@@ -0,0 +1,91 @@
.TH NUNTIUS 1 2026-09-28 "nuntius 1.0.0" "nuntius manual"
.SH NAME
nuntius \- contact form backend
.SH SYNOPSIS
.B nuntius
.RB [ \-\-version ]
.RB [ \-\-check\-config ]
.SH DESCRIPTION
.B nuntius
serves multiple JSON form endpoints (contact, feedback, newsletter,
generic) from a single static binary, validates each submission, delivers
it by SMTP and keeps per-form rate limits, CORS allowlists and a honeypot
field. Newsletter forms use a double opt-in: the address is recorded only
after the subscriber redeems the confirmation link from the mail.
.PP
Configuration is read from a TOML file, /etc/nuntius/config.toml by
default. The first start writes a three-form starter template when the
file does not exist, and every ${VAR} reference inside it is expanded
from the environment before parsing, so secrets stay out of the file.
With no flags the server listens on the configured address and serves
until SIGINT or SIGTERM, then drains in-flight requests and exits.
.SH OPTIONS
.TP
.B \-\-version
Print the release the binary was built at, then exit. A build at a tag
reports the tag, a build from a commit reports a pseudo-version naming
that commit, and a build outside version control reports (devel); a dirty
tree appends +dirty.
.TP
.B \-\-check\-config
Load and validate the configuration file, then exit without listening.
Exits nonzero and names the problem on any error, so it can run as a
systemd ExecStartPre.
.SH EXIT STATUS
.TP
.B 0
The requested operation succeeded: the version was printed, the
configuration is valid, or the server shut down cleanly.
.TP
.B 1
The configuration is missing, invalid or fails validation, or the server
failed to serve.
.SH CONFIGURATION
The full schema, every key with its type, default and effect, is in
.I docs/CONFIGURATION.md
in the repository. Configuration is one TOML file holding a
.B [server]
table, a
.B data_dir
and one or more
.B [[forms]]
entries, each with its own SMTP settings, rate limit, CORS allowlist and
honeypot field.
.SH ENVIRONMENT
.TP
.B NUNTIUS_CONFIG
Path of the configuration file to read instead of
/etc/nuntius/config.toml.
.SH FILES
.TP
.I /etc/nuntius/config.toml
The default configuration file; written as a starter template on first
start when missing.
.TP
.I data_dir/newsletter-<name>.jsonl
The append-only newsletter subscriber log, where data_dir is the
configured data directory (./data by default).
.SH EXAMPLES
Print the release:
.PP
.RS
.nf
nuntius \-\-version
.fi
.RE
.PP
Validate a configuration without listening:
.PP
.RS
.nf
NUNTIUS_CONFIG=./config.toml nuntius \-\-check\-config
.fi
.RE
.SH AUTHOR
Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
.SH LICENCE
MIT License
.SH SEE ALSO
.IR docs/CONFIGURATION.md ,
.IR docs/API.md ,
.IR docs/DEPLOYMENT.md
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=nuntius contact form backend
After=network.target
[Service]
Type=simple
User=nuntius
Group=nuntius
WorkingDirectory=/var/lib/nuntius
EnvironmentFile=/etc/nuntius/.env
ExecStartPre=/usr/local/bin/nuntius --check-config
ExecStart=/usr/local/bin/nuntius
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env perl
# Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
# SPDX-License-Identifier: MIT
#
# install.pl: 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 perl install.pl
#
# 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.
use strict;
use warnings;
my $nuntius_user = 'nuntius';
my $bin_dest = '/usr/local/bin/nuntius';
my $config_dir = '/etc/nuntius';
my $config_file = '/etc/nuntius/config.toml';
my $env_file = '/etc/nuntius/.env';
my $data_dir = '/var/lib/nuntius';
my $service_dest = '/etc/systemd/system/nuntius.service';
sub note { print qq{==> $_[0]\n} }
sub warn_ { print qq{WARN: $_[0]\n} }
sub fail { print qq{ERROR: $_[0]\n}; exit 1 }
# run executes one child in list form, so no argument is ever word-split or
# globbed, and dies naming the attempt when the child fails.
sub run {
my (@cmd) = @_;
system(@cmd) == 0
or die qq{ERROR: could not run '$cmd[0]': exit code } . ($? >> 8) . qq{\n};
}
# silenced runs a block with STDOUT and STDERR pointed at /dev/null, restored
# afterwards, so the caller sees only what it decides to print.
sub silenced(&) {
open(my $save_out, '>&', \*STDOUT) or die qq{ERROR: could not save STDOUT: $!\n};
open(my $save_err, '>&', \*STDERR) or die qq{ERROR: could not save STDERR: $!\n};
open(STDOUT, '>', '/dev/null') or die qq{ERROR: could not silence STDOUT: $!\n};
open(STDERR, '>&', \*STDOUT) or die qq{ERROR: could not silence STDERR: $!\n};
my $result = $_[0]->();
open(STDOUT, '>&', $save_out) or die qq{ERROR: could not restore STDOUT: $!\n};
open(STDERR, '>&', $save_err) or die qq{ERROR: could not restore STDERR: $!\n};
return $result;
}
sub require_root {
$> == 0 or fail('Run as root: sudo perl install.pl');
}
sub require_files {
-f './nuntius'
or fail('./nuntius binary not found: copy it here first (e.g. rsync bin/nuntius).');
-f './nuntius.service'
or fail('./nuntius.service not found: copy it from the repository root.');
}
sub create_user {
if (silenced { system('id', $nuntius_user) == 0 }) {
note("User $nuntius_user already exists.");
return;
}
note("Creating system user $nuntius_user...");
run('useradd', '--system', '--shell', '/usr/sbin/nologin',
'--home-dir', $data_dir, '--user-group', $nuntius_user);
}
sub create_data_dir {
note("Setting up $data_dir...");
unless (-d $data_dir) {
mkdir($data_dir, 0750) or die qq{ERROR: could not create $data_dir: $!\n};
}
my $uid = getpwnam($nuntius_user)
or die qq{ERROR: could not look up user $nuntius_user\n};
my $gid = getgrnam($nuntius_user)
or die qq{ERROR: could not look up group $nuntius_user\n};
chown($uid, $gid, $data_dir) or die qq{ERROR: could not chown $data_dir: $!\n};
chmod(0750, $data_dir) or die qq{ERROR: could not chmod $data_dir: $!\n};
}
sub install_binary {
note("Installing binary to $bin_dest...");
run('install', '-m', '0755', './nuntius', $bin_dest);
}
sub install_service {
note("Installing systemd unit $service_dest...");
run('install', '-m', '0644', './nuntius.service', $service_dest);
}
sub generate_config {
if (-f $config_file) {
note("Config $config_file already exists; leaving it untouched.");
return;
}
note("Generating $config_file...");
unless (-d $config_dir) {
mkdir($config_dir, 0755) or die qq{ERROR: could not create $config_dir: $!\n};
}
# nuntius writes the template config on first start; run it briefly, then
# let `timeout` send SIGTERM (graceful shutdown). The write happens at
# startup, before the timeout elapses. timeout exits 124 on SIGTERM and
# 137 on SIGKILL, so its status is deliberately not checked here.
silenced { system('timeout', '-k', '5s', '4s', $bin_dest) };
if (!-f $config_file) {
warn_("nuntius did not create $config_file; check the uploaded binary.");
}
# Fix ownership in case nuntius wrote files as root.
run('chown', '-R', "$nuntius_user:$nuntius_user", $data_dir);
}
sub install_env {
if (-f $env_file) {
note("$env_file already exists; leaving it untouched.");
}
elsif (-f './.env.example') {
note("Creating $env_file from .env.example (edit it!)...");
run('install', '-m', '0600', './.env.example', $env_file);
run('chown', "root:$nuntius_user", $env_file);
}
else {
warn_(".env.example not found; skipping $env_file. Create it before starting.");
}
}
sub enable_service {
note('Reloading systemd and enabling nuntius...');
run('systemctl', 'daemon-reload');
run('systemctl', 'enable', 'nuntius.service');
}
sub final_notes {
print <<"END_NOTES";
============================================================
nuntius installed and enabled, but NOT started.
Next manual steps:
1. Set the SMTP password:
sudo \$EDITOR $env_file
2. Edit the auto-generated config (smtp.host, smtp.user, allowed_origins):
sudo \$EDITOR $config_file
3. Start the service:
sudo systemctl start nuntius
sudo journalctl -u nuntius -f
4. Add the Caddy reverse_proxy directive (see README.md), then:
sudo caddy validate && sudo systemctl reload caddy
============================================================
END_NOTES
}
require_root();
require_files();
create_user();
create_data_dir();
install_binary();
install_service();
generate_config();
install_env();
enable_service();
final_notes();