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
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:
@@ -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
|
||||
@@ -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 ./...
|
||||
@@ -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);
|
||||
'
|
||||
@@ -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
@@ -0,0 +1,13 @@
|
||||
.idea/
|
||||
.zcode/
|
||||
|
||||
# Build output
|
||||
bin/
|
||||
*.out
|
||||
coverage.html
|
||||
*.test
|
||||
|
||||
# Local secrets and config
|
||||
.env*
|
||||
!.env.example
|
||||
config.toml
|
||||
+107
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,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
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,5 @@
|
||||
module sourcedock.dev/petrbalvin/nuntius
|
||||
|
||||
go 1.27.1
|
||||
|
||||
require sourcedock.dev/petrbalvin/interpres/v2 v2.0.0
|
||||
@@ -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=
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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]")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+173
@@ -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();
|
||||
Reference in New Issue
Block a user