diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3bb12dc3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Keep the build context small + avoid copying host build output into the image. +**/bin +**/obj +**/node_modules +**/dist +src/Dispatch.Web/wwwroot +.git +.github +installer +docs +publish +staging +TestResults +**/*.user +.vs +.vscode +*.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f2e61453 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Keep GitHub Actions current. Third-party actions are pinned to commit SHAs (supply-chain hardening); + # Dependabot bumps those SHAs (and the trailing version comment) when new releases ship, so pinning + # doesn't mean going stale. Grouped into one PR to keep the noise down. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + + # Frontend dependencies (npm) for the dashboard. + - package-ecosystem: npm + directory: "/src/Dispatch.UI" + schedule: + interval: weekly + groups: + ui: + patterns: ["*"] + + # .NET NuGet dependencies. + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + groups: + nuget: + patterns: ["*"] diff --git a/.github/workflows/appliance.yml b/.github/workflows/appliance.yml new file mode 100644 index 00000000..c123d3c3 --- /dev/null +++ b/.github/workflows/appliance.yml @@ -0,0 +1,180 @@ +name: Appliance + +# Builds the downloadable virtual appliances for ALL hypervisors in one job: customizes the Ubuntu 24.04 +# cloud image with libguestfs (no Hyper-V host / nested virt needed) into a Gen2/UEFI image with SQL Server +# Express + Dispatch baked in, emits it as VHDX (Hyper-V), OVA (VMware), and qcow2 (KVM/Proxmox), then boots +# it under QEMU/UEFI to prove the service comes up on first boot. Artifacts: dispatch-appliance-hyperv, +# dispatch-appliance-vmware, dispatch-appliance-kvm. +on: + # Dispatch-only: the Build workflow kicks this off automatically AFTER its tests + lint pass (see the + # trigger-appliance job in build.yml), so the appliance is never built from un-validated code and never + # races Build. Also runnable by hand from the Actions tab. + workflow_dispatch: + +env: + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + +jobs: + # ONE job builds all three appliance formats (Hyper-V VHDX, VMware OVA, KVM/Proxmox qcow2) from a + # single image, then uploads them as three separate downloads near the end. The name reflects that + # it is not Hyper-V-only. + build-appliances: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: src/Dispatch.UI/package-lock.json + + - name: Build + embed the web UI + run: | + ( cd src/Dispatch.UI && npm ci && npm run build ) + rm -rf src/Dispatch.Web/wwwroot && mkdir -p src/Dispatch.Web/wwwroot + cp -r src/Dispatch.UI/dist/* src/Dispatch.Web/wwwroot/ + test -f src/Dispatch.Web/wwwroot/index.html || { echo "embedded UI missing index.html"; exit 1; } + + - name: Publish self-contained (linux-x64) + run: dotnet publish src/Dispatch.Service -c Release -r linux-x64 --self-contained true -o publish/linux + + - name: Install image-build tooling + run: | + sudo apt-get update + sudo apt-get install -y libguestfs-tools qemu-utils + # libguestfs's appliance must be able to read the host kernel image. + sudo chmod 0644 /boot/vmlinuz-* || true + + - name: Build the appliance VHDX + env: + VERSION: ${{ github.ref_type == 'tag' && github.ref_name || format('ci-{0}', github.run_number) }} + run: | + sudo env PREBUILT_DIR="$PWD/publish/linux" VERSION="$VERSION" \ + OUT="$PWD/dispatch-appliance.vhdx" OVA_OUT="$PWD/dispatch-appliance.ova" \ + QCOW2_OUT="$PWD/dispatch-appliance.qcow2" \ + LIBGUESTFS_BACKEND=direct ./appliance/build-appliance.sh + sudo chown "$USER" dispatch-appliance.vhdx dispatch-appliance.ova dispatch-appliance.qcow2 + + - name: Validate the OVA (structure, OVF XML, manifest checksums) + run: | + echo "=== tar contents (must be ovf, then mf, then vmdk) ===" + tar -tf dispatch-appliance.ova + first=$(tar -tf dispatch-appliance.ova | head -1) + case "$first" in *.ovf) ;; *) echo "ERROR: first OVA entry must be the .ovf, got $first"; exit 1;; esac + mkdir -p ova-check && tar -xf dispatch-appliance.ova -C ova-check + python3 -c "import xml.dom.minidom,glob,sys; xml.dom.minidom.parse(glob.glob('ova-check/*.ovf')[0]); print('OVF XML parses')" + ( cd ova-check && while read -r line; do + f=$(echo "$line" | sed -n 's/^SHA256(\(.*\))=.*/\1/p'); h=$(echo "$line" | sed -n 's/.*= *//p') + [ -n "$f" ] || continue + act=$(sha256sum "$f" | cut -d' ' -f1) + [ "$act" = "$h" ] && echo "ok: $f" || { echo "ERROR: checksum mismatch for $f"; exit 1; } + done < ./*.mf ) + echo "OVA structure + checksums valid" + + # One self-contained download per hypervisor: each artifact downloads as a single zip (GitHub wraps + # it) holding that format's image + its README + helper - so the user unzips ONCE, no nested zip. + - name: Stage per-type downloads + checksums + run: | + mkdir -p out/hyperv out/vmware out/kvm + # Hyper-V: raw VHDX (GitHub compresses the artifact) + import helper + instructions. + mv dispatch-appliance.vhdx out/hyperv/ + cp appliance/Import-DispatchAppliance.ps1 out/hyperv/ + cp appliance/hyperv-README.txt out/hyperv/README.txt + ( cd out/hyperv && sha256sum dispatch-appliance.vhdx > dispatch-appliance.vhdx.sha256 ) + # VMware: the OVA + instructions. + mv dispatch-appliance.ova out/vmware/ + cp appliance/vmware-README.txt out/vmware/README.txt + ( cd out/vmware && sha256sum dispatch-appliance.ova > dispatch-appliance.ova.sha256 ) + # KVM / Proxmox: qcow2 + import helpers + instructions. + mv dispatch-appliance.qcow2 out/kvm/ + cp appliance/import-libvirt.sh appliance/import-proxmox.sh out/kvm/ + cp appliance/kvm-README.txt out/kvm/README.txt + ( cd out/kvm && sha256sum dispatch-appliance.qcow2 > dispatch-appliance.qcow2.sha256 ) + + # Upload before the boot smoke so the artifacts are available even if the smoke fails. + - name: Upload Hyper-V appliance + uses: actions/upload-artifact@v6 + with: { name: dispatch-appliance-hyperv, path: out/hyperv/*, if-no-files-found: error } + - name: Upload VMware appliance + uses: actions/upload-artifact@v6 + with: { name: dispatch-appliance-vmware, path: out/vmware/*, if-no-files-found: error } + - name: Upload KVM/Proxmox appliance + uses: actions/upload-artifact@v6 + with: { name: dispatch-appliance-kvm, path: out/kvm/*, if-no-files-found: error } + + - name: Boot smoke (UEFI) - service comes up on first boot + run: | + sudo apt-get install -y qemu-system-x86 ovmf imagemagick + code=$(ls /usr/share/OVMF/OVMF_CODE*.fd | head -1) + varsrc=$(ls /usr/share/OVMF/OVMF_VARS*.fd | head -1) + cp "$varsrc" vars.fd + echo "OVMF: $code / $varsrc" + # -cpu host requires KVM; under the TCG fallback use a TCG-compatible CPU model. + if [ -e /dev/kvm ]; then echo "KVM available"; ACCEL="-enable-kvm -cpu host"; else echo "KVM NOT available - TCG (slow)"; ACCEL="-accel tcg -cpu max"; fi + # Boot the image as qcow2 (rules out QEMU's vhdx driver - Hyper-V reads the vhdx itself) and attach + # it as an explicit virtio-blk device with bootindex=0, so OVMF (empty NVRAM) actually boots it. + # -vga std + a QMP socket let us screenshot the VGA console (firmware/GRUB output goes there, not serial). + qemu-img convert -O qcow2 out/hyperv/dispatch-appliance.vhdx boot.qcow2 + # shellcheck disable=SC2086 + sudo qemu-system-x86_64 -machine q35 $ACCEL -m 4096 -smp 2 -no-reboot \ + -drive if=pflash,format=raw,readonly=on,file="$code" \ + -drive if=pflash,format=raw,file=vars.fd \ + -drive file=boot.qcow2,format=qcow2,if=none,id=hd0 \ + -device virtio-blk-pci,drive=hd0,bootindex=0 \ + -netdev user,id=n0,hostfwd=tcp::8420-:8420,hostfwd=tcp::2526-:25 -device virtio-net-pci,netdev=n0 \ + -vga std -display none -serial file:console.log -qmp unix:qmp.sock,server,nowait -daemonize 2>qemu.err \ + || { echo "=== qemu failed to launch ==="; cat qemu.err; exit 1; } + screenshot() { # capture the VGA framebuffer via QMP -> $1 (png) + sudo python3 appliance/qmp-screendump.py qmp.sock /tmp/screen.ppm || true + sudo convert /tmp/screen.ppm "$1" 2>/dev/null && echo "captured $1" || echo "no screenshot" + } + ok=0 + for i in $(seq 1 90); do # up to ~8 min (KVM boot is fast; this is mostly SQL first-run) + if curl -fsSk -m 3 https://localhost:8420/health >/dev/null 2>&1; then ok=1; break; fi + [ "$i" = 18 ] && screenshot boot-90s.png # ~90s in: capture whatever's on screen + sleep 5 + done + if [ "$ok" != 1 ]; then + screenshot boot-final.png + echo "=== qemu.err ==="; cat qemu.err 2>/dev/null || true + echo "=== serial console ==="; cat console.log 2>/dev/null || echo "(serial console empty)" + echo "=== qemu still running? ==="; pgrep -f qemu-system >/dev/null && echo "(qemu running)" || echo "(qemu not running)" + # qemu (sudo) owns these root-only; make them readable so upload-artifact can collect them. + sudo chmod -f a+r boot-90s.png boot-final.png console.log qemu.err 2>/dev/null || true + echo "appliance did not serve /health within the timeout"; exit 1 + fi + echo "appliance /health OK" + + # Prove the appliance bound the privileged SMTP port (25, via CAP_NET_BIND_SERVICE on the systemd + # unit) and that STARTTLS negotiates with the self-signed fallback cert. Guest 25 is forwarded to + # host 2526; openssl completes a real TLS handshake (BEGIN CERTIFICATE) on success. + tls_ok=0 + for _ in $(seq 1 12); do + if echo QUIT | openssl s_client -starttls smtp -connect localhost:2526 -crlf 2>/dev/null | grep -q 'BEGIN CERTIFICATE'; then + tls_ok=1; break + fi + sleep 5 + done + if [ "$tls_ok" != 1 ]; then + echo "=== serial console ==="; sudo chmod -f a+r console.log 2>/dev/null || true; cat console.log 2>/dev/null || true + echo "appliance SMTP STARTTLS check failed on port 25 (forwarded to 2526)"; exit 1 + fi + echo "appliance SMTP STARTTLS on port 25 OK" + + - name: Upload boot diagnostics + if: failure() + uses: actions/upload-artifact@v6 + with: + name: appliance-boot-diagnostics + path: | + boot-90s.png + boot-final.png + console.log + qemu.err + if-no-files-found: warn diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..d4b22d45 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,117 @@ +name: Build + +on: + push: + pull_request: + +env: + # The actions/* @v4 run on Node 20 (deprecated); opt them into Node 24 now (forced default 2026-06-16). + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + build-test: + runs-on: ubuntu-latest + + services: + # SQL Server 2022 service container (GitHub-hosted runners are free for this public repo). Same wire + # protocol / T-SQL as the dev Azure SQL Edge container; app code is unchanged. The Data test fixture + # waits up to 90s for the engine to accept connections, so no container health-cmd is needed. + sql: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: "Y" + # Dev-only SA password. Override by setting the MSSQL_SA_PASSWORD repo/org secret; the literal is + # only a fallback and grants nothing beyond the ephemeral CI container. + MSSQL_SA_PASSWORD: ${{ secrets.MSSQL_SA_PASSWORD || 'Dispatch_Dev_Pass123' }} + ports: + - 1433:1433 + + env: + # Drives the Dispatch.Data integration tests against the SQL service container. + DISPATCH_TEST_SQL: "Server=localhost,1433;User Id=sa;Password=${{ secrets.MSSQL_SA_PASSWORD || 'Dispatch_Dev_Pass123' }};TrustServerCertificate=True;Encrypt=True" + # Make the integration tests fail loudly if the connection string is ever missing, instead of skipping. + DISPATCH_REQUIRE_SQL: "1" + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: src/Dispatch.UI/package-lock.json + + - name: Build React UI + working-directory: src/Dispatch.UI + run: | + npm ci + npm run build + + - name: Embed UI into Dispatch.Web + run: | + rm -rf src/Dispatch.Web/wwwroot + mkdir -p src/Dispatch.Web/wwwroot + cp -r src/Dispatch.UI/dist/* src/Dispatch.Web/wwwroot/ + # Fail fast if the embed produced nothing (stale/empty dist would otherwise ship a blank dashboard). + test -s src/Dispatch.Web/wwwroot/index.html || { echo "::error::Embedded UI is missing index.html"; exit 1; } + + - name: Restore + run: dotnet restore Dispatch.slnx + + - name: Build + run: dotnet build Dispatch.slnx --configuration Release --no-restore + + - name: Test + run: dotnet test Dispatch.slnx --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" --results-directory TestResults + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-results + path: TestResults/**/*.trx + if-no-files-found: ignore + + # Keep em dashes out of the tree: they break Windows PowerShell parsing (a mis-decoded em-dash byte + # becomes a smart-quote string delimiter) and the project standardizes on ASCII '-'. + no-em-dash: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Reject em dashes (use ASCII '-') + run: | + matches=$(grep -rlIP '\x{2014}' . --exclude-dir=.git --exclude-dir=node_modules || true) + if [ -n "$matches" ]; then + echo "::error::Em dash (U+2014) found - replace with an ASCII hyphen '-'. Files:" + echo "$matches" + exit 1 + fi + echo "OK: no em dashes." + + # After a green Build on a working branch, rebuild the appliance from THIS validated commit so the image + # always has the latest tested code and never races Build. workflow_run can't be used (it only fires from + # the default branch), so we dispatch the Appliance workflow with GITHUB_TOKEN (workflow_dispatch is an + # allowed GITHUB_TOKEN trigger). `needs: build-test` gates it on success. + trigger-appliance: + needs: [build-test, no-em-dash] + if: ${{ github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'feat/relay-engine-web-providers') }} + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - uses: actions/checkout@v5 + - name: Build the appliance from this validated commit + env: + GH_TOKEN: ${{ github.token }} + run: gh workflow run appliance.yml --ref "${{ github.ref_name }}" + + # NOTE: tagged releases (refs/tags/v*) are handled by release.yml, which builds the signed Windows + # installer + Linux tarball and publishes a GitHub Release. This workflow only runs build + tests. diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..a64dd3cd --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,47 @@ +name: Docker + +# Builds the container image and runs the full stack (Dispatch + SQL via docker-compose), failing unless +# the service comes up and serves /health - the container equivalent of the installer smoke tests. Docker is +# native on the runner, so this needs no QEMU/nested virtualization. The release workflow builds+pushes the +# multi-arch image; this proves it actually runs before that. +on: + workflow_dispatch: + push: + paths: + - "Dockerfile" + - "docker-compose.yml" + - "src/Dispatch.Service/**" + - "src/Dispatch.Web/**" + - "src/Dispatch.Core/**" + - "src/Dispatch.Data/**" + - "src/Dispatch.Providers/**" + - "src/Dispatch.UI/**" + - ".github/workflows/docker.yml" + +jobs: + build-and-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Build image + start the stack (Dispatch + SQL) + run: docker compose up --build -d + + - name: Wait for the dashboard /health + run: | + ok=0 + for _ in $(seq 1 60); do # up to 5 min: image already built; SQL start + DB init + service + if curl -fsSk -m 5 https://localhost:8420/health >/dev/null 2>&1; then ok=1; break; fi + sleep 5 + done + if [ "$ok" != 1 ]; then + echo "=== compose ps ==="; docker compose ps + echo "=== dispatch logs ==="; docker compose logs --tail 150 dispatch || true + echo "=== sql logs ==="; docker compose logs --tail 40 sql || true + echo "dispatch /health did not respond"; exit 1 + fi + echo "Docker stack /health OK" + + - name: Tear down + if: always() + run: docker compose down -v || true diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml new file mode 100644 index 00000000..3ccd662e --- /dev/null +++ b/.github/workflows/installers.yml @@ -0,0 +1,365 @@ +name: Installers + +# Validates the installer artifacts on GitHub-hosted runners (free for this public repo). Non-destructive +# by default: the Windows job builds the MSI + Burn bundle (proving the WiX sources + net472 launcher +# compile) and the Linux job lints install.sh. The actual SQL-Express install + service smoke test is +# destructive (it installs SQL Server on the runner), so it only runs when dispatched with full_install=true. +on: + workflow_dispatch: + inputs: + full_install: + description: "Also run the real SQL install + service smoke test (mutates the runner)" + type: boolean + default: false + push: + paths: + - "installer/**" + - ".github/workflows/installers.yml" + +env: + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + # The actions/* @v4 run on Node 20 (deprecated); opt them into Node 24 now (forced default 2026-06-16). + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + windows-installer: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: src/Dispatch.UI/package-lock.json + + - name: Build + embed the web UI + shell: pwsh + run: | + Push-Location src/Dispatch.UI; npm ci; npm run build; Pop-Location + Remove-Item -Recurse -Force src/Dispatch.Web/wwwroot -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force src/Dispatch.Web/wwwroot | Out-Null + Copy-Item -Recurse -Force src/Dispatch.UI/dist/* src/Dispatch.Web/wwwroot/ + if (-not (Test-Path src/Dispatch.Web/wwwroot/index.html)) { throw "embedded UI missing index.html" } + + - name: Publish the service + # Self-contained: bundle the .NET runtime so the service runs on a clean Windows box without a + # separately-installed .NET 10 runtime (the bundle does not chain one). Matches the Linux build. + run: dotnet publish src/Dispatch.Service -c Release -r win-x64 --self-contained true -o installer/windows/publish + + # Guard against a framework-dependent regression: the runner has .NET, so an install+smoke test would + # still pass with a broken FDD build - but a clean customer box would fail with "You must install .NET". + # Assert the .NET runtime is actually bundled, which a self-contained publish guarantees. + - name: Assert publish is self-contained (bundles the .NET runtime) + shell: pwsh + run: | + $missing = @('hostfxr.dll','coreclr.dll','System.Private.CoreLib.dll') | + Where-Object { -not (Test-Path "installer/windows/publish/$_") } + if ($missing) { + throw "Publish is NOT self-contained (missing: $($missing -join ', ')). The service would require a separately-installed .NET runtime and fail to start on a clean machine." + } + Write-Host "OK: self-contained .NET runtime is bundled." + + - name: Install the WiX toolset + shell: pwsh + env: + WIX_VERSION: "6.0.2" + run: | + dotnet tool install --global wix --version $env:WIX_VERSION + # Extensions must match the toolset major version (the unpinned latest is v7, incompatible with v6). + # In WiX v6 the bundle/Bal extension dll is shipped as WixToolset.BootstrapperApplications.wixext. + wix extension add -g "WixToolset.Firewall.wixext/$env:WIX_VERSION" + wix extension add -g "WixToolset.BootstrapperApplications.wixext/$env:WIX_VERSION" + wix extension add -g "WixToolset.Util.wixext/$env:WIX_VERSION" + + - name: Build the MSI + working-directory: installer/windows + run: wix build Dispatch.wxs -arch x64 -d PublishDir=publish -ext WixToolset.Firewall.wixext -o Dispatch.msi + + - name: Build the SQL Express launcher + run: dotnet build installer/windows/sql-express/SqlExpressLauncher.csproj -c Release + + - name: Build the bundle + working-directory: installer/windows + shell: pwsh + run: | + wix build bundle/Bundle.wxs -ext WixToolset.BootstrapperApplications.wixext -ext WixToolset.Util.wixext ` + -bindpath "SqlLauncher=sql-express/bin/Release/net472" -bindpath "Msi=." -o DispatchSetup.exe + + # CI installer builds are intentionally unsigned (signing happens only on tagged releases via + # release.yml + Azure Artifact Signing). This artifact proves the WiX sources + launcher compile. + # DispatchSetup.exe is the single distributable file - it embeds Dispatch.msi and chains SQL Express. + # (The MSI is still built above to prove the WiX sources compile and to embed in the bundle.) + - name: Upload installer artifact + uses: actions/upload-artifact@v6 + with: + name: dispatch-windows-installer + path: installer/windows/DispatchSetup.exe + if-no-files-found: error + + # Real end-to-end check: run the bundle (installs SQL Express + the service) and confirm the Windows + # service starts and serves /health. Runs on every installer-path push and on manual dispatch, so a + # service that won't start (bad config, missing files, crash on boot) fails CI instead of the customer. + - name: Full install + smoke + if: ${{ github.event_name == 'push' || inputs.full_install == true }} + shell: pwsh + run: | + $p = Start-Process -Wait -PassThru -FilePath installer/windows/DispatchSetup.exe -ArgumentList '/quiet','/log','install.log' + Write-Host "Bundle exit code: $($p.ExitCode)" + $ok = $false + foreach ($i in 1..24) { + try { if ((Invoke-WebRequest -UseBasicParsing -SkipCertificateCheck -TimeoutSec 5 https://localhost:8420/health).StatusCode -eq 200) { $ok = $true; break } } catch {} + Start-Sleep -Seconds 5 + } + if (-not $ok) { + Write-Host "=== bundle + MSI logs (Burn writes per-package install_*.log) ===" + Get-ChildItem -Path . -Filter "install*.log" -ErrorAction SilentlyContinue | ForEach-Object { + Write-Host "----- $($_.Name) -----"; Get-Content $_.FullName | Select-Object -Last 80 + } + Write-Host "=== SQL bootstrap log ==="; Get-Content C:\Windows\Temp\Dispatch-SqlInstall.log -ErrorAction SilentlyContinue + Write-Host "=== Application event log (Dispatch / .NET Runtime / Application Error) ===" + try { + Get-WinEvent -FilterHashtable @{ LogName = 'Application'; StartTime = (Get-Date).AddMinutes(-30) } -ErrorAction SilentlyContinue | + Where-Object { $_.ProviderName -match 'Dispatch|\.NET Runtime|Application Error' } | + Select-Object -First 15 | Format-List TimeCreated, ProviderName, Message + } catch {} + Write-Host "=== Dispatch service ==="; Get-Service Dispatch -ErrorAction SilentlyContinue | Format-List Name, Status, StartType + Write-Host "=== Dispatch app log ==="; Get-Content "$env:ProgramData\Dispatch\logs\*.log" -ErrorAction SilentlyContinue | Select-Object -Last 40 + throw "health check failed after 120s (bundle exit $($p.ExitCode))" + } + Write-Host "Dispatch /health OK" + + # Assert the installer locked down the data dir (the WriteAppSettings.js CA runs icacls): inheritance + # disabled and no non-admin principals, so the connection string, spool (message bodies), the + # .dispatch-key encryption key, and logs aren't readable by other local users. + - name: Verify the data dir is locked down (ACL) + if: ${{ github.event_name == 'push' || inputs.full_install == true }} + shell: pwsh + run: | + $dir = "$env:ProgramData\Dispatch" + $acl = Get-Acl $dir + if (-not $acl.AreAccessRulesProtected) { throw "ACL inheritance is NOT disabled on $dir" } + $bad = $acl.Access | Where-Object { + $_.AccessControlType -eq 'Allow' -and ($_.IdentityReference.Value -match '\\Users$|Everyone|Authenticated Users') + } + if ($bad) { throw "$dir is accessible to non-admins: $(($bad.IdentityReference.Value) -join ', ')" } + if (-not (Test-Path "$dir\.dispatch-key")) { throw ".dispatch-key was not created in $dir" } + Write-Host "OK: $dir locked to SYSTEM + Administrators; .dispatch-key present" + icacls $dir + + # Assert SMTP STARTTLS is offered out of the box (the self-signed fallback). Probe the standard ports + # and the 2525 fallback; at least one is bound. EHLO must advertise STARTTLS. + - name: Verify SMTP STARTTLS is offered + if: ${{ github.event_name == 'push' || inputs.full_install == true }} + shell: pwsh + run: | + $ok = $false + foreach ($port in 25,587,2525) { + $tcp = New-Object System.Net.Sockets.TcpClient + try { $tcp.Connect('127.0.0.1', $port) } catch { $tcp.Dispose(); continue } + try { + $stream = $tcp.GetStream(); $stream.ReadTimeout = 5000 + $reader = New-Object System.IO.StreamReader($stream) + $writer = New-Object System.IO.StreamWriter($stream); $writer.NewLine = "`r`n"; $writer.AutoFlush = $true + $banner = $reader.ReadLine() + $writer.WriteLine("EHLO ci.test") + $caps = @() + while ($true) { $line = $reader.ReadLine(); if ($null -eq $line) { break }; $caps += $line; if ($line -notmatch '^250-') { break } } + Write-Host "port ${port}: $banner :: $($caps -join ' | ')" + if ($caps -match 'STARTTLS') { try { $writer.WriteLine('QUIT') } catch {}; $ok = $true } + } catch { Write-Host "port ${port}: $($_.Exception.Message)" } finally { $tcp.Dispose() } + if ($ok) { break } + } + if (-not $ok) { throw "No SMTP port advertised STARTTLS (the self-signed fallback should enable it)" } + Write-Host "OK: STARTTLS advertised" + + # End-to-end SMTP access-control check on the real install: tighten the source-IP allow-list to exclude + # loopback, confirm a localhost MAIL FROM is refused (550), and that the denial both appears in the + # Message Log AND is counted in /stats and Reports (the relay_id 0 counter path). Restore the list after. + - name: Verify SMTP allow-list denial flows to the log + reports + if: ${{ github.event_name == 'push' || inputs.full_install == true }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $base = 'https://localhost:8420' + $hdr = @{ 'X-Dispatch-Request' = '1' } + $sess = New-Object Microsoft.PowerShell.Commands.WebRequestSession + + # First-run: set the admin password (allowed without auth when none exists) - this also signs us in. + Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Headers $hdr -ContentType 'application/json' -Method Post -Uri "$base/api/auth/password" -Body '{"password":"Zq7-Marsh-Pylon-Vex!"}' | Out-Null + + # Tighten the SMTP allow-list to a range that excludes loopback (the PUT reloads the cache live). + Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Headers $hdr -ContentType 'application/json' -Method Put -Uri "$base/api/config/listener" -Body '{"allowedCidrs":["10.0.0.0/8"]}' | Out-Null + + try { + $health = Invoke-RestMethod -SkipCertificateCheck -Uri "$base/health" + $port = 25 + if ($health.smtp.listeningPorts) { $port = $health.smtp.listeningPorts[0] } elseif ($health.smtp.ports) { $port = $health.smtp.ports[0] } + + # Raw SMTP from loopback (now disallowed): EHLO then MAIL FROM must get a 5xx access-denied. + $tcp = New-Object System.Net.Sockets.TcpClient('127.0.0.1', [int]$port) + $stream = $tcp.GetStream(); $stream.ReadTimeout = 5000 + $r = New-Object System.IO.StreamReader($stream) + $w = New-Object System.IO.StreamWriter($stream); $w.NewLine = "`r`n"; $w.AutoFlush = $true + $r.ReadLine() | Out-Null + $w.WriteLine('EHLO ci.test') + do { $line = $r.ReadLine() } while ($line -match '^250-') + $w.WriteLine('MAIL FROM:') + $resp = $r.ReadLine() + $tcp.Close() + Write-Host "MAIL FROM -> $resp" + if ($resp -notmatch '^5') { throw "Expected a 5xx denial from a disallowed source, got: $resp" } + + # The denial must appear in the Message Log... + $log = Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Uri "$base/api/messages?event=Denied&pageSize=5" + if ([int]$log.total -lt 1) { throw "denial not found in the Message Log" } + + # ...and be counted in /stats and Reports (verifies the relay_id 0 counter fix on a real install). + $stats = Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Uri "$base/api/stats" + if ([int]$stats.denied -lt 1) { throw "denial not counted in /stats" } + $today = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') + $rep = Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Uri "$base/api/reports?from=$today&to=$today" + if ([int]$rep.summary.denied -lt 1) { throw "denial not counted in Reports" } + + Write-Host "OK: denial refused, logged (total=$($log.total)), counted in /stats ($($stats.denied)) + Reports ($($rep.summary.denied))" + } + finally { + # Restore the default allow-list (loopback + private ranges). + Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Headers $hdr -ContentType 'application/json' -Method Put -Uri "$base/api/config/listener" -Body '{"allowedCidrs":["127.0.0.1/32","::1/128","10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","fc00::/7"]}' | Out-Null + } + + # Comprehensive functional QC on the real install: end-to-end delivery (API + SMTP over STARTTLS), + # API-key revocation, and SMTP AUTH over STARTTLS. See installer/windows/functional-smoke.ps1. + - name: Functional smoke (delivery, revoke, SMTP AUTH over STARTTLS) + if: ${{ github.event_name == 'push' || inputs.full_install == true }} + shell: pwsh + run: ./installer/windows/functional-smoke.ps1 + + # Builds the self-contained Linux tarball and uploads it as a workflow artifact (downloadable from + # the Actions run page). This needs NO tag and creates NO release - releases are only for permanent + # public download links. Grab "dispatch-linux-tarball" from the run to test install.sh --prebuilt. + linux-tarball: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: src/Dispatch.UI/package-lock.json + + - name: Build + embed the web UI + run: | + ( cd src/Dispatch.UI && npm ci && npm run build ) + rm -rf src/Dispatch.Web/wwwroot + mkdir -p src/Dispatch.Web/wwwroot + cp -r src/Dispatch.UI/dist/* src/Dispatch.Web/wwwroot/ + test -f src/Dispatch.Web/wwwroot/index.html || { echo "embedded UI missing index.html"; exit 1; } + + - name: Publish self-contained (linux-x64) + run: dotnet publish src/Dispatch.Service -c Release -r linux-x64 --self-contained true -o publish/linux + + # Guard against a framework-dependent regression (the runner has .NET, which would mask it at runtime). + - name: Assert publish is self-contained (bundles the .NET runtime) + run: | + missing= + for f in libcoreclr.so libhostfxr.so System.Private.CoreLib.dll; do + [ -f "publish/linux/$f" ] || missing="$missing $f" + done + if [ -n "$missing" ]; then + echo "Publish is NOT self-contained (missing:$missing) - would require a separately-installed .NET runtime." >&2 + exit 1 + fi + echo "OK: self-contained .NET runtime is bundled." + + - name: Assemble the tarball + run: | + stage="dispatch-ci-linux-x64" + mkdir -p "staging/${stage}/bin" + cp -r publish/linux/. "staging/${stage}/bin/" + cp installer/linux/install.sh "staging/${stage}/install.sh" + cp installer/linux/dispatch.service "staging/${stage}/dispatch.service" + chmod +x "staging/${stage}/install.sh" "staging/${stage}/bin/Dispatch.Service" + tar -czf "${stage}.tar.gz" -C staging "${stage}" + ls -lh "${stage}.tar.gz" + + - name: Upload tarball artifact + uses: actions/upload-artifact@v6 + with: + name: dispatch-linux-tarball + path: dispatch-ci-linux-x64.tar.gz + if-no-files-found: error + + # Real end-to-end check (mirrors the Windows full-install smoke): install the just-built self-contained + # tarball exactly as a user would - install.sh --prebuilt --install-sql, which installs SQL Server + # locally and provisions the DispatchLog DB - then confirm the systemd service starts and serves + # /health. Runs on installer-path pushes and manual dispatch so a service that won't start (or a broken + # SQL bootstrap) fails CI instead of the customer. + - name: Full install + smoke + if: ${{ github.event_name == 'push' || inputs.full_install == true }} + env: + SA_PW: "Ci-Smoke-Passw0rd!" + run: | + set -euo pipefail + mkdir -p /tmp/extract && tar -xzf dispatch-ci-linux-x64.tar.gz -C /tmp/extract + dir=/tmp/extract/dispatch-ci-linux-x64 + sudo "$dir/install.sh" --prebuilt "$dir/bin" --install-sql \ + --sa-password "$SA_PW" --admin-password "$SA_PW" + + ok=0 + for _ in $(seq 1 36); do + if curl -fsSk -m 5 https://localhost:8420/health >/dev/null 2>&1; then ok=1; break; fi + sleep 5 + done + if [ "$ok" != 1 ]; then + echo "=== dispatch service ==="; sudo systemctl status dispatch --no-pager || true + echo "=== journal ==="; sudo journalctl -u dispatch --no-pager -n 80 || true + echo "=== app logs ==="; sudo tail -n 60 /var/log/dispatch/*.log 2>/dev/null || true + echo "=== sql server ==="; sudo systemctl status mssql-server --no-pager 2>/dev/null || true + echo "health check failed"; exit 1 + fi + curl -fsSk https://localhost:8420/health; echo " <- /health OK" + + # Validate the SMTP listener bound a privileged port (CAP_NET_BIND_SERVICE) and that STARTTLS + # negotiates end-to-end using the self-signed fallback cert. openssl actually completes the TLS + # handshake, so a missing/broken cert fails here. Probe 25/587 then the 2525 fallback. + echo "=== STARTTLS check ===" + tls_ok=0 + for p in 25 587 2525; do + if echo QUIT | openssl s_client -starttls smtp -connect 127.0.0.1:$p -crlf 2>/dev/null | grep -q 'BEGIN CERTIFICATE'; then + echo "STARTTLS negotiated on port $p"; tls_ok=1; break + fi + done + [ "$tls_ok" = 1 ] || { echo "STARTTLS negotiation failed on all SMTP ports"; exit 1; } + + linux-installer: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Lint install.sh + run: | + bash -n installer/linux/install.sh + if command -v shellcheck >/dev/null 2>&1; then + shellcheck -S warning installer/linux/install.sh || true + else + echo "shellcheck not installed - syntax check only" + fi + + # The end-to-end install + "service actually runs" smoke lives in the linux-tarball job below - it + # installs the real self-contained tarball (install.sh --prebuilt) against a SQL container and checks + # /health, mirroring the Windows full-install smoke. This job stays a fast lint of install.sh. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..be404a94 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,387 @@ +name: Release + +# Cut a release by pushing a semver tag: +# git tag v1.2.3 && git push origin v1.2.3 +# +# This builds the distributable artifacts, stamps them with the tag version, optionally Authenticode-signs +# the Windows installer via Azure Artifact Signing, and publishes a GitHub Release with: +# - DispatchSetup.exe the single-file Windows installer (embeds the signed MSI + chains SQL Express) +# - dispatch--linux.tar.gz universal Linux bundle (x64 + arm64, self-contained) +# - SHA256SUMS checksums for all of the above +# +# Code signing is OPT-IN and dormant until you provision Azure Artifact Signing (renamed from Trusted +# Signing, Jan 2026) and set these repo settings: +# Variables: AZURE_SIGNING_ENDPOINT (e.g. https://eus.codesigning.azure.net/), +# AZURE_SIGNING_ACCOUNT, AZURE_CERT_PROFILE +# Secrets: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID (federated/OIDC service principal) +# Until AZURE_SIGNING_ACCOUNT is set, the release still publishes - just unsigned. + +on: + push: + tags: + - "v*" + # Allow a dry run against the current ref without tagging (publishes a draft prerelease). + workflow_dispatch: + inputs: + version: + description: "Version for a manual dry-run build (e.g. 0.0.0-test)" + type: string + default: "0.0.0-dryrun" + +permissions: + contents: write # create the GitHub Release + id-token: write # Azure OIDC login for signing + packages: write # push the container image to GHCR + +env: + DOTNET_NOLOGO: "true" + DOTNET_CLI_TELEMETRY_OPTOUT: "true" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + windows: + runs-on: windows-latest + outputs: + version: ${{ steps.ver.outputs.version }} + steps: + - uses: actions/checkout@v5 + + - name: Resolve version + id: ver + shell: bash + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then v="${GITHUB_REF_NAME#v}"; else v="${{ inputs.version }}"; fi + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "Building version $v" + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: src/Dispatch.UI/package-lock.json + + - name: Build + embed the web UI + shell: pwsh + run: | + Push-Location src/Dispatch.UI; npm ci; npm run build; Pop-Location + Remove-Item -Recurse -Force src/Dispatch.Web/wwwroot -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force src/Dispatch.Web/wwwroot | Out-Null + Copy-Item -Recurse -Force src/Dispatch.UI/dist/* src/Dispatch.Web/wwwroot/ + if (-not (Test-Path src/Dispatch.Web/wwwroot/index.html)) { throw "embedded UI missing index.html" } + + - name: Publish the service + # Self-contained: bundle the .NET runtime so the service runs on a clean Windows box without a + # separately-installed .NET 10 runtime (the bundle does not chain one). Matches the Linux build. + run: dotnet publish src/Dispatch.Service -c Release -r win-x64 --self-contained true -p:Version=${{ steps.ver.outputs.version }} -o installer/windows/publish + + - name: Install the WiX toolset + shell: pwsh + env: + WIX_VERSION: "6.0.2" + run: | + dotnet tool install --global wix --version $env:WIX_VERSION + wix extension add -g "WixToolset.Firewall.wixext/$env:WIX_VERSION" + wix extension add -g "WixToolset.BootstrapperApplications.wixext/$env:WIX_VERSION" + wix extension add -g "WixToolset.Util.wixext/$env:WIX_VERSION" + + - name: Build the MSI + working-directory: installer/windows + run: wix build Dispatch.wxs -arch x64 -d PublishDir=publish -d Version=${{ steps.ver.outputs.version }} -ext WixToolset.Firewall.wixext -o Dispatch.msi + + # --- Sign the MSI (opt-in) ---------------------------------------------------------------- + - name: Azure login (signing) + if: ${{ vars.AZURE_SIGNING_ACCOUNT != '' }} + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign the MSI + if: ${{ vars.AZURE_SIGNING_ACCOUNT != '' }} + uses: azure/trusted-signing-action@fc390cf8ed0f14e248a542af1d838388a47c7a7c # v0.5 + with: + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + trusted-signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_CERT_PROFILE }} + files-folder: ${{ github.workspace }}/installer/windows + files-folder-filter: msi + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Build the SQL Express launcher + run: dotnet build installer/windows/sql-express/SqlExpressLauncher.csproj -c Release + + - name: Build the bundle + working-directory: installer/windows + shell: pwsh + run: | + wix build bundle/Bundle.wxs -d Version=${{ steps.ver.outputs.version }} ` + -ext WixToolset.BootstrapperApplications.wixext -ext WixToolset.Util.wixext ` + -bindpath "SqlLauncher=sql-express/bin/Release/net472" -bindpath "Msi=." -o DispatchSetup.exe + + # --- Sign the Burn bundle (opt-in) -------------------------------------------------------- + # A Burn bundle has an attached engine; the engine must be signed too, so detach it, sign it, + # reattach, then sign the final bundle (WiX-recommended; otherwise UAC flags the inner engine). + # installer/windows holds other .exe files (publish/, sql-express/), so each Azure sign step + # targets a folder containing ONLY the one file to sign, and we copy the result back in place. + - name: Detach the bundle engine + if: ${{ vars.AZURE_SIGNING_ACCOUNT != '' }} + working-directory: installer/windows + shell: pwsh + run: | + New-Item -ItemType Directory -Force engine | Out-Null + wix burn detach DispatchSetup.exe -engine engine/engine.exe + + - name: Sign the engine + if: ${{ vars.AZURE_SIGNING_ACCOUNT != '' }} + uses: azure/trusted-signing-action@fc390cf8ed0f14e248a542af1d838388a47c7a7c # v0.5 + with: + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + trusted-signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_CERT_PROFILE }} + files-folder: ${{ github.workspace }}/installer/windows/engine + files-folder-filter: exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Reattach the signed engine + stage bundle for signing + if: ${{ vars.AZURE_SIGNING_ACCOUNT != '' }} + working-directory: installer/windows + shell: pwsh + run: | + wix burn reattach DispatchSetup.exe -engine engine/engine.exe -o DispatchSetup.exe + New-Item -ItemType Directory -Force bundlesign | Out-Null + Copy-Item -Force DispatchSetup.exe bundlesign/DispatchSetup.exe + + - name: Sign the bundle + if: ${{ vars.AZURE_SIGNING_ACCOUNT != '' }} + uses: azure/trusted-signing-action@fc390cf8ed0f14e248a542af1d838388a47c7a7c # v0.5 + with: + endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + trusted-signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.AZURE_CERT_PROFILE }} + files-folder: ${{ github.workspace }}/installer/windows/bundlesign + files-folder-filter: exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Put the signed bundle back in place + if: ${{ vars.AZURE_SIGNING_ACCOUNT != '' }} + working-directory: installer/windows + shell: pwsh + run: Copy-Item -Force bundlesign/DispatchSetup.exe DispatchSetup.exe + + - name: Upload Windows artifact + uses: actions/upload-artifact@v6 + with: + name: windows + # DispatchSetup.exe is the single distributable - it embeds the (signed) MSI and chains SQL Express. + path: installer/windows/DispatchSetup.exe + if-no-files-found: error + + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Resolve version + id: ver + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then v="${GITHUB_REF_NAME#v}"; else v="${{ inputs.version }}"; fi + echo "version=$v" >> "$GITHUB_OUTPUT" + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Setup Node + uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: src/Dispatch.UI/package-lock.json + + - name: Build + embed the web UI + run: | + ( cd src/Dispatch.UI && npm ci && npm run build ) + rm -rf src/Dispatch.Web/wwwroot + mkdir -p src/Dispatch.Web/wwwroot + cp -r src/Dispatch.UI/dist/* src/Dispatch.Web/wwwroot/ + test -f src/Dispatch.Web/wwwroot/index.html || { echo "embedded UI missing index.html"; exit 1; } + + - name: Publish self-contained (linux-x64 + linux-arm64 + win-x64) + run: | + v=${{ steps.ver.outputs.version }} + dotnet publish src/Dispatch.Service -c Release -r linux-x64 --self-contained true -p:Version=$v -o publish/x64 + dotnet publish src/Dispatch.Service -c Release -r linux-arm64 --self-contained true -p:Version=$v -o publish/arm64 + # win-x64 cross-published on Linux - the self-contained Windows payload for the one upgrade package. + dotnet publish src/Dispatch.Service -c Release -r win-x64 --self-contained true -p:Version=$v -o publish/win + # Stamp the version so install.sh names the release dir (/opt/dispatch/releases/). + for d in x64 arm64 win; do echo "$v" > "publish/$d/.dispatch-version"; done + + - name: Assemble the universal Linux tarball + run: | + ver="${{ steps.ver.outputs.version }}" + stage="dispatch-${ver}-linux" + mkdir -p "staging/${stage}/bin-x64" "staging/${stage}/bin-arm64" + cp -r publish/x64/. "staging/${stage}/bin-x64/" + cp -r publish/arm64/. "staging/${stage}/bin-arm64/" + cp installer/linux/install.sh installer/linux/dispatch.service \ + installer/linux/dispatch-updater.service installer/linux/dispatch-update.path \ + installer/linux/dispatch-update.sh "staging/${stage}/" + cp src/Dispatch.Core/Updates/dispatch-update-public.pem "staging/${stage}/" + chmod +x "staging/${stage}/install.sh" "staging/${stage}/dispatch-update.sh" \ + "staging/${stage}/bin-x64/Dispatch.Service" "staging/${stage}/bin-arm64/Dispatch.Service" + cat > "staging/${stage}/README.txt" <' --admin-password '' + + On arm64, --install-sql runs Azure SQL Edge in a container (SQL Server is amd64-only). + Or use an existing SQL Server / Azure SQL: + sudo ./install.sh --sql-connection 'Server=...;Database=DispatchLog;User Id=...;Password=...;TrustServerCertificate=True;Encrypt=True' --admin-password '' + + Then open https://localhost:8420 (self-signed cert) and log in with the admin password you set. + EOF + tar -czf "${stage}.tar.gz" -C staging "${stage}" + ls -lh "${stage}.tar.gz" + + - name: Upload Linux artifact + uses: actions/upload-artifact@v6 + with: + name: linux + path: dispatch-*-linux.tar.gz + if-no-files-found: error + + # ONE cross-platform upgrade package for web-UI self-update: every platform's self-contained payload + # (linux-x64, linux-arm64, win-x64) plus a single signed manifest, in one .tar.gz. A host uploads + # this one file; the updater picks + applies the payload matching its arch and verifies its sha256. + # The manifest signature is checked against the embedded public key. Signing is gated on the secret; + # without it the package is unsigned and the app/updater refuse it. + - name: Build + sign the upgrade package + env: + SIGNING_KEY: ${{ secrets.DISPATCH_UPDATE_SIGNING_KEY }} + run: | + ver="${{ steps.ver.outputs.version }}" + built="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + notes="https://github.com/${{ github.repository }}/releases/tag/v${ver}" + mkdir -p pkg + tar -czf pkg/linux-x64.tar.gz -C publish/x64 . + tar -czf pkg/linux-arm64.tar.gz -C publish/arm64 . + ( cd publish/win && zip -qr - . ) > pkg/win-x64.zip + shx="$(sha256sum pkg/linux-x64.tar.gz | awk '{print $1}')" + sha="$(sha256sum pkg/linux-arm64.tar.gz | awk '{print $1}')" + shw="$(sha256sum pkg/win-x64.zip | awk '{print $1}')" + printf '{"name":"dispatch","version":"%s","minFromVersion":"0.0.0","builtAt":"%s","notesUrl":"%s","artifacts":{"linux-x64":{"file":"linux-x64.tar.gz","sha256":"%s"},"linux-arm64":{"file":"linux-arm64.tar.gz","sha256":"%s"},"win-x64":{"file":"win-x64.zip","sha256":"%s"}}}\n' \ + "$ver" "$built" "$notes" "$shx" "$sha" "$shw" > pkg/manifest.json + if [ -n "$SIGNING_KEY" ]; then + keyfile="$(mktemp)"; printf '%s' "$SIGNING_KEY" > "$keyfile" + openssl dgst -sha256 -sign "$keyfile" -out pkg/manifest.json.sig pkg/manifest.json + # Self-check: the signature must verify against the committed public key before we ship it. + openssl dgst -sha256 -verify src/Dispatch.Core/Updates/dispatch-update-public.pem -signature pkg/manifest.json.sig pkg/manifest.json + rm -f "$keyfile" + else + echo "::error::DISPATCH_UPDATE_SIGNING_KEY is not set - refusing to publish an unsigned upgrade package (it would be rejected by every installer anyway). Set the signing-key secret to cut a release." + exit 1 + fi + tar -czf "dispatch-upgrade-${ver}.tar.gz" -C pkg manifest.json manifest.json.sig linux-x64.tar.gz linux-arm64.tar.gz win-x64.zip + ls -lh "dispatch-upgrade-${ver}.tar.gz" + + - name: Upload upgrade package + uses: actions/upload-artifact@v6 + with: + name: upgrade-package + path: dispatch-upgrade-*.tar.gz + if-no-files-found: error + + # Build + push the multi-arch container image (linux/amd64 + linux/arm64) to GHCR. One image tag + # serves both architectures; `docker pull ghcr.io//dispatch-smtp-relay:` works on any host. + # Only runs for real tag releases (a dry-run workflow_dispatch skips the push). + docker: + if: ${{ github.ref_type == 'tag' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Set up QEMU (cross-arch emulation) + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Image metadata (tags + labels) + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 + with: + images: ghcr.io/${{ github.repository }} + # full semver + major.minor; `latest` only for stable (non-prerelease) tags. + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} + + - name: Build + push multi-arch image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + publish: + needs: [windows, linux] + runs-on: ubuntu-latest + steps: + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + path: dist + + - name: Stage release files + checksums + env: + VER: ${{ needs.windows.outputs.version }} + run: | + mkdir -p release + # Version the Windows asset names so each release's downloads are distinguishable + # (the Linux tarball already carries the version). Names match the README. + cp "dist/windows/DispatchSetup.exe" "release/DispatchSetup-${VER}-x64.exe" + cp dist/linux/dispatch-*-linux.tar.gz release/ + cp dist/upgrade-package/* release/ # the single cross-platform upgrade package + ( cd release && sha256sum * > SHA256SUMS && cat SHA256SUMS ) + + - name: Publish GitHub Release + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}', needs.windows.outputs.version) }} + name: Dispatch ${{ needs.windows.outputs.version }} + draft: ${{ github.ref_type != 'tag' }} + prerelease: ${{ contains(needs.windows.outputs.version, '-') }} + generate_release_notes: true + fail_on_unmatched_files: true + files: | + release/DispatchSetup-*-x64.exe + release/dispatch-*-linux.tar.gz + release/dispatch-upgrade-* + release/SHA256SUMS diff --git a/.github/workflows/scripts.yml b/.github/workflows/scripts.yml new file mode 100644 index 00000000..46b93a51 --- /dev/null +++ b/.github/workflows/scripts.yml @@ -0,0 +1,61 @@ +name: Scripts + +# Static + behavioral checks for the PowerShell helpers (the Hyper-V import + Windows installer/smoke +# scripts) which can't run on a normal install: parse + PSScriptAnalyzer on Linux pwsh, and Pester tests +# (with the Hyper-V cmdlets mocked) on Windows. Not a substitute for a live Hyper-V import, but it turns +# "carefully reviewed" into "parsed, linted, and logic-tested on every push". + +on: + push: + paths: + - "appliance/**.ps1" + - "installer/windows/**.ps1" + - ".github/workflows/scripts.yml" + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Parse + analyze PowerShell + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -Force -Scope CurrentUser + $dirs = 'appliance', 'installer/windows' + $fail = $false + + # Hard syntax errors. + Get-ChildItem -Path $dirs -Recurse -Filter *.ps1 | ForEach-Object { + $errs = $null + [System.Management.Automation.Language.Parser]::ParseFile($_.FullName, [ref]$null, [ref]$errs) | Out-Null + if ($errs) { + $fail = $true + Write-Host "::error::Parse errors in $($_.FullName)" + $errs | ForEach-Object { Write-Host " line $($_.Extent.StartLineNumber): $($_.Message)" } + } + } + + # Static analysis - fail on Error severity only (Write-Host is intentional CLI output). + $results = foreach ($d in $dirs) { Invoke-ScriptAnalyzer -Path $d -Recurse -Severity Error -ExcludeRule PSAvoidUsingWriteHost } + if ($results) { $fail = $true; ($results | Format-Table -AutoSize | Out-String) | Write-Host } + + if ($fail) { throw 'PowerShell parse/lint failed.' } + Write-Host 'OK: PowerShell parses and lints clean.' + + pester: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - name: Pester (Hyper-V import logic, cmdlets mocked) + shell: pwsh + run: | + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module Pester -MinimumVersion 5.5.0 -Force -Scope CurrentUser -SkipPublisherCheck + Import-Module Pester + $cfg = New-PesterConfiguration + $cfg.Run.Path = 'appliance' + $cfg.Run.Exit = $true + $cfg.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $cfg diff --git a/.gitignore b/.gitignore index cc728f2d..fca8c76b 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,16 @@ appsettings.Development.json appsettings.Local.json *.pfx -# Spool / runtime data -spool/ -*.eml +# Node / React UI build artifacts +node_modules/ +dist/ +# Generated: UI build copied here for embedding (run npm build + copy) +src/Dispatch.Web/wwwroot/ + +# Spool / runtime data (the runtime spool dir; NOT the src/.../Spool source folder - +# git's core.ignorecase makes a bare "spool/" match "Spool/", so scope it to the dev path) +.dispatch-spool/ +logs/ # OS .DS_Store @@ -26,3 +33,7 @@ Thumbs.db # Claude Code local settings .claude/settings.local.json + +# At-rest encryption key (never commit) +.dispatch-key +**/.dispatch-key diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..89de054f --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + + 0.2.0 + + diff --git a/Dispatch.slnx b/Dispatch.slnx new file mode 100644 index 00000000..b77612bc --- /dev/null +++ b/Dispatch.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..37867b71 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1 +# +# Dispatch SMTP Relay - container image (multi-arch: linux/amd64 + linux/arm64). +# +# Build: docker build -t dispatch . +# Run: see docker-compose.yml (brings up Dispatch + SQL together). +# +# Config (spec §12.1): the image takes ONLY the two bootstrap settings from the environment - +# ConnectionStrings__DispatchLog the SQL connection string (required) +# AdminPassword first-run dashboard admin password seed (optional) +# Everything else is seeded into the SQL config table on first run and managed in the dashboard. + +# --- Stage 1: build the React dashboard ------------------------------------------------------- +FROM node:24-alpine AS ui +WORKDIR /ui +COPY src/Dispatch.UI/package*.json ./ +RUN npm ci +COPY src/Dispatch.UI/ ./ +RUN npm run build + +# --- Stage 2: publish the .NET service with the UI embedded ----------------------------------- +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +# The dashboard is embedded into Dispatch.Web as wwwroot (spec §19.4); drop the built assets in +# before publish so they get embedded. +RUN rm -rf src/Dispatch.Web/wwwroot && mkdir -p src/Dispatch.Web/wwwroot +COPY --from=ui /ui/dist/ src/Dispatch.Web/wwwroot/ +RUN dotnet publish src/Dispatch.Service -c Release -o /app + +# --- Stage 3: runtime ------------------------------------------------------------------------ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +# curl is used only by the container HEALTHCHECK below (the slim runtime image ships without it). +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* +COPY --from=build /app ./ +# Spool (./.dispatch-spool, resolved against the content root /app) and logs live on volumes. +# Store the at-rest encryption key in the (persistent) spool volume so it survives container recreation - +# the content root /app is ephemeral. Without this the key would change each recreate and stored provider +# secrets couldn't be decrypted. +ENV DISPATCH_LOG_DIR=/var/log/dispatch +ENV DISPATCH_KEY_DIR=/app/.dispatch-spool +RUN mkdir -p /var/log/dispatch /app/.dispatch-spool +# Dashboard (8420), ingestion API (8025), SMTP (25 & 587) - all configurable in the dashboard afterwards. +# The container runs as root so it can bind the privileged SMTP ports; the listener falls back to 2525 only +# if 25 is unavailable. +EXPOSE 8420 8025 25 587 +# Report container health from the unauthenticated /health endpoint. The dashboard is HTTPS-only with a +# self-signed cert by default, so use https + -k. start-period covers first-run schema init + SQL connect. +HEALTHCHECK --interval=15s --timeout=5s --start-period=40s --retries=4 \ + CMD curl -fsSk https://localhost:8420/health || exit 1 +# UseSystemd()/UseWindowsService() in Program.cs are no-ops here; the service runs as PID 1. +ENTRYPOINT ["./Dispatch.Service"] diff --git a/LICENSE b/LICENSE index ed57031a..0f7b7db1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,687 +1,201 @@ -"Commons Clause" License Condition v1.0 - -The Software is provided to you by the Licensor under the License, as defined -below, subject to the following condition. - -Without limiting other conditions in the License, the grant of rights under the -License will not include, and the License does not grant to you, the right to -Sell the Software. - -For purposes of the foregoing, "Sell" means practicing any or all of the rights -granted to you under the License to provide to third parties, for a fee or other -consideration (including without limitation fees for hosting or -consulting/support services related to the Software), a product or service whose -value derives, entirely or substantially, from the functionality of the -Software. Any license notice or attribution required by the License must also -include this Commons Clause License Condition notice. - -Software: Dispatch SMTP Relay -License: AGPL-3.0-only -Licensor: Chris Muench - -The full text of the AGPL-3.0 license, which applies subject to the Commons -Clause condition above, follows. - -=============================================================================== - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 CinderHills + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 730a8a07..89cefb53 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,14 @@ # Dispatch SMTP Relay -**Open-source .NET SMTP relay — forward mail from your apps to any cloud provider** +**Self-hosted .NET email relay - forward mail from your apps to any cloud provider** -[![Build](https://github.com/chrismuench/Dispatch-SMTP-Relay/actions/workflows/build.yml/badge.svg)](https://github.com/chrismuench/Dispatch-SMTP-Relay/actions) -[![License: AGPL v3 + Commons Clause](https://img.shields.io/badge/License-AGPL_v3_%2B_Commons_Clause-blue.svg)](LICENSE) -[![Latest Release](https://img.shields.io/github/v/release/chrismuench/Dispatch-SMTP-Relay)](https://github.com/chrismuench/Dispatch-SMTP-Relay/releases/latest) -[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-lightgrey)](#installation) +[![Build](https://github.com/CinderHillsDev/dispatch-platform/actions/workflows/build.yml/badge.svg)](https://github.com/CinderHillsDev/dispatch-platform/actions) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Docker-lightgrey)](https://docs.dispatchrelay.app/deployment/overview/) -Point your applications and devices at Dispatch on port 25 or 587. Dispatch queues every message durably and forwards it to Mailgun, SendGrid, Azure Communication Services, or any SMTP smart host — with a live web dashboard to monitor, configure, and troubleshoot everything. +Point your applications and devices at Dispatch over **SMTP** (the standard ports **25** and **587** by default - it falls back to **2525** only if 25 is already taken) or a Mailgun-compatible **HTTP API**. Dispatch queues every message durably and forwards it to a dozen providers - Mailgun, SendGrid, Amazon SES, Postmark, Resend, SparkPost, SMTP2GO, Maileroo, Bird, Azure Communication Services - or any SMTP smart host, with a live web dashboard to monitor, configure, and troubleshoot everything. + +### 📚 **[Read the documentation →](https://docs.dispatchrelay.app/)** ![Dispatch Dashboard Screenshot](docs/images/dashboard.png) @@ -23,8 +23,8 @@ Most applications need to send email. Wiring every app directly to a cloud provi ``` Your apps / devices → Dispatch SMTP (port 25/587) ─┐ - ├→ Mailgun / SendGrid / Azure / SMTP -Your apps / scripts → Dispatch API (port 8081) ─┘ + ├→ Mailgun / SendGrid / Azure / SMTP … +Your apps / scripts → Dispatch API (port 8025) ─┘ ↑ ↕ 202 / 250 OK instantly spool directory (before any DB or (durable in-flight queue) @@ -32,286 +32,87 @@ Your apps / scripts → Dispatch API (port 8081) ─┘ relay_log in SQL (after-the-fact history) ↕ - Web UI (port 8080) + Web UI (port 8420) Configure · Monitor · Debug ``` -- **`250 OK` before anything else** — Dispatch writes the message to a local spool file and acknowledges the sender immediately. No database, no HTTP call, just a file write. SQL Server is only written to *after* the provider accepts the message -- **Spool directory is the queue** — `.eml` files survive restarts, crashes, and SQL outages. If SQL Server is down, mail still flows -- **One place to manage credentials** — rotate an API key once, not in every app -- **Full message log** — after-the-fact history in SQL Server, searchable in the UI -- **Test before you commit** — verify provider credentials with a live relay log before saving - ---- +- **`250 OK` before anything else** - Dispatch writes the message to a local spool file and acknowledges the sender immediately. SQL Server is written to only *after* the provider accepts the message. +- **The spool directory is the queue** - `.eml` files survive restarts, crashes, and SQL outages. If SQL is down, mail still flows. +- **One place for credentials** - rotate an API key once, not in every app. +- **Test before you commit** - verify provider credentials with a live relay log, or capture mail to the **Local Inbox** without delivering anything. ## Features -| | | -|---|---| -| 📨 **SMTP listener** | Accepts on ports 25 and 587; STARTTLS, AUTH; app-layer CIDR allow-list; denied connections logged | -| 🌐 **HTTP ingestion API** | `POST /api/v1/messages` on port 8081; multipart or JSON; API key auth; Mailgun-compatible | -| ⚡ **Instant 250 OK** | Message written to spool directory before acknowledging sender — no DB or network on the hot path | -| 📁 **Spool queue** | Local `.eml` files are the durable queue; survive crashes, restarts, and SQL outages | -| 🔀 **Relay routing** | Named relay configs; domain/subdomain routing rules; default relay catch-all; simulate tool | -| ☁️ **Provider support** | Mailgun, SendGrid, Azure Communication Services, generic SMTP, dev/none mode | -| 🔄 **Auto-retry** | Exponential back-off (30 s → 5 min → 30 min); failed messages in `spool/failed/` with retry-from-UI | -| 🖥️ **Web UI** | Embedded React dashboard; no separate web server needed | -| 📊 **Message log** | After-the-fact history in SQL Server; searchable, filterable; CSV export | -| 🧪 **Provider testing** | Send a real test email from the settings page; watch the relay log live | -| 🗑️ **Auto-purge** | Time-based retention + size-based pressure purge (triggers at 9.5 GB, target 9.0 GB) | -| 🔒 **Security** | Encrypted credential storage; optional UI password; localhost-first defaults | -| 🪟 **Windows** | Installs as a Windows Service; MSI with firewall rules; GUI bootstrap wizard | -| 🐧 **Linux** | Runs as a systemd unit; interactive bash installer | -| ⬆️ **Upgrades** | Spool drain → schema migration → binary swap; rollback on failure | - ---- - -## Installation - -### Windows +- **Two ways in** - SMTP (STARTTLS, optional AUTH) and a Mailgun-compatible HTTP/HTTPS API with per-key tokens. +- **A dozen providers** - Mailgun, SendGrid, Amazon SES, Postmark, Resend, SparkPost, SMTP2GO, Maileroo, Bird, Azure Communication Services, generic SMTP - plus **Local** capture mode. CC/BCC, attachments, and custom headers everywhere. +- **[Local Inbox](https://docs.dispatchrelay.app/sending/local-inbox/)** - a built-in mail trap: capture and inspect what your app sends, without delivering anything. Great for development and CI. +- **Durable spool** - instant `250 OK`, auto-retry with back-off, retry-from-UI for failures. +- **Smart routing** - send by sender/recipient domain to different relays, with a catch-all default and a simulate tool. +- **Live dashboard** - real-time counters, a searchable message log with sandboxed HTML preview, reports, and one-click provider testing. +- **Secure by default** - HTTPS-only dashboard, a shared TLS cert for SMTP + the API, bcrypt-hashed passwords & API keys, encrypted secrets at rest, CIDR allow-lists so it's never an open relay. +- **Privacy first - no call home** - no telemetry, no analytics, no usage stats, no automatic update pings. The only outbound connections are to the mail providers you configure; updates are applied from a signed package you upload yourself. +- **Observability** - unauthenticated `/health` and Prometheus `/metrics` (dashboard-port, allow-list-gated). +- **Deploy anywhere** - Windows & Linux installers (bundled SQL), a multi-arch Docker image, or a ready-to-import virtual appliance for Hyper-V, VMware, KVM & Proxmox. -Download and run `DispatchSetup-{version}-x64.exe` from the [latest release](https://github.com/chrismuench/Dispatch-SMTP-Relay/releases/latest). - -The setup wizard: -1. Detects any existing SQL Server instance on your machine -2. Offers to install SQL Server Express silently if none is found, or lets you connect to an existing instance -3. Creates the `DispatchQueue` database and applies the schema -4. Installs Dispatch as a Windows Service and opens the dashboard in your browser - -**Silent install** (SQL Server already present): -```bat -DispatchSetup-1.0.0-x64.exe --silent --server "localhost\SQLEXPRESS" --auth windows -``` +## Quickstart -### Linux +The fastest way to try Dispatch is Docker Compose - it brings up Dispatch **and** its database together: ```bash -curl -sSL https://github.com/chrismuench/Dispatch-SMTP-Relay/releases/latest/download/install.sh | sudo bash +git clone https://github.com/CinderHillsDev/dispatch-platform.git +cd dispatch-platform +docker compose up -d --build +# dashboard → https://localhost:8420 (self-signed cert; default login password in docker-compose.yml) ``` -The script detects or installs SQL Server Express, creates the database, installs the systemd service, and prints the dashboard URL when done. +Then open **https://localhost:8420**, set the admin password, add a relay, and point your apps at `localhost:25` (SMTP) or `localhost:8025` (HTTP API). -**Check the service:** -```bash -sudo systemctl status dispatch -sudo journalctl -u dispatch -f -``` - ---- - -## Quick Start - -1. Open the dashboard at **https://localhost:8080** (accept the self-signed certificate warning on first visit, or replace the cert with one from your internal CA) -2. Go to **Settings → Relay Provider** and enter your provider credentials -3. Click **Send Test Email** to verify the credentials work — watch the live relay log -4. Click **Save Settings** -5. Point your application at `localhost:25` or `localhost:587` and send mail +> **Tip:** Dispatch listens on the standard SMTP ports **25** and **587**. Install it on a host with **no other SMTP software** (Postfix, Sendmail, Exim, …) so those ports are free - otherwise Dispatch falls back to **2525**. -That's it. Dispatch handles everything from there. +➡️ Full guide: **[Quickstart](https://docs.dispatchrelay.app/start/quickstart/)** · **[How it works](https://docs.dispatchrelay.app/start/how-it-works/)** -### Sending via HTTP API +## Documentation -If you prefer HTTP over SMTP, use the ingestion API on port 8081: +Everything lives on the docs site: **https://docs.dispatchrelay.app/** -```bash -# Create an API key in the web UI first (Settings → API Keys), then: -curl -X POST http://localhost:8081/api/v1/messages \ - -H "Authorization: Bearer dsp_live_your_key_here" \ - -F from="App " \ - -F to="user@example.com" \ - -F subject="Hello" \ - -F html="

Hello world

" \ - -F text="Hello world" -# → 202 Accepted: { "id": "spl_a1b2c3d4", "message": "Queued. Thank you." } -``` - -Supports `multipart/form-data` (with file attachments) and `application/json`. The API is intentionally similar to Mailgun's `/messages` endpoint. - ---- - -## Configuration - -All settings are managed through the web UI and stored in SQL Server. The only thing in `appsettings.json` is the database connection string — there is nothing else to edit manually. - -### Getting to the UI - -Open **http://localhost:8080** after installation. On first run, all settings have sensible defaults. The only required step is entering your relay provider credentials under **Settings → Relay Provider**. - -### SMTP Listener - -| Setting | Default | Notes | -|---|---|---| -| Ports | 25, 587 | Comma-separated list | -| Bind address | 0.0.0.0 | Listens on all interfaces | -| Allowed IPs / CIDRs | `127.0.0.1/32` | Add your subnet, e.g. `192.168.1.0/24`; denied connections are logged | -| Require AUTH | false | Enable to require username/password from senders | -| Max message size | 25 MB | Capped to active provider limit automatically | -| TLS certificate | — | Path to PFX file for STARTTLS support | - -### Relay Providers - -| Provider | Required settings | -|---|---| -| **Mailgun** | API Key, Domain, Region (US/EU) | -| **SendGrid** | API Key | -| **Azure Communication Services** | Connection String, Sender Address | -| **SMTP (generic)** | Host, Port, Username, Password, TLS mode | -| **None (dev mode)** | — accepts and discards all mail | - -### Retention - -Delivered log entries are purged after **30 days** by default. Failed entries are kept for **90 days**. Spool files in `failed/` are purged after **30 days**. - -Dispatch also monitors database size and automatically purges the oldest log rows when the database approaches **9.5 GB** — keeping it safely below the SQL Server Express 10 GB limit. - -All retention periods and size thresholds are configurable under **Settings → Storage & Retention**. - ---- - -## Supported Providers - -| Provider | Mechanism | +| | | |---|---| -| [Mailgun](https://mailgun.com) | REST API | -| [SendGrid](https://sendgrid.com) | Web API v3 (official SDK) | -| [Azure Communication Services](https://azure.microsoft.com/en-us/products/communication-services) | SDK | -| Any SMTP smart host | SMTP via MailKit (AWS SES, Office 365, Postfix, …) | - -More providers planned — see [Appendix A of the spec](docs/SPEC.md) and [open issues](https://github.com/chrismuench/Dispatch-SMTP-Relay/issues?q=label%3Aprovider). - ---- - -## Screenshots +| **[Deployment](https://docs.dispatchrelay.app/deployment/overview/)** | Docker, Linux, Windows, and the virtual appliance | +| **[Relay providers](https://docs.dispatchrelay.app/providers/overview/)** | Per-provider setup and settings | +| **[Sending mail](https://docs.dispatchrelay.app/sending/smtp/)** | SMTP, HTTP API, Local Inbox, message features | +| **[Routing](https://docs.dispatchrelay.app/routing/)** | Route by sender/recipient domain | +| **[Configuration](https://docs.dispatchrelay.app/configuration/overview/)** | Settings model + full config-key reference | +| **[Security](https://docs.dispatchrelay.app/security/)** | Auth, access control, encryption, TLS | +| **[API reference](https://docs.dispatchrelay.app/reference/api/)** | The HTTP ingestion API | -
-Dashboard - -![Dashboard](docs/images/dashboard.png) - -
- -
-Message Log - -![Message Log](docs/images/message-log.png) - -
- -
-Provider Settings with live test log - -![Provider Settings](docs/images/provider-settings.png) - -
- -
-Storage & Retention - -![Storage and Retention](docs/images/retention.png) - -
- ---- +## Security -## Requirements +Safe by default: the dashboard is HTTPS-only, secrets are encrypted at rest (AES-256-GCM with a portable, access-restricted key file on every platform - so a database backup can be restored on another machine by also restoring the key file), passwords and API keys are bcrypt-hashed, and SMTP + the API are closed to anything outside private ranges so a fresh install is never an open relay. -### Windows -- Windows 10 / Windows Server 2019 or later (x64) -- .NET 9 runtime (bundled in the installer) -- SQL Server Express 2019+ or any SQL Server edition (installer can download and set up Express for you) +**Privacy first - no call home.** Dispatch never phones home: no telemetry, no analytics, no usage stats, and no automatic update polling. The only outbound connections it makes are to the mail providers you configure; updates are applied only from a signed package you upload yourself. Nothing about your install or your mail is sent to us or any third party. -### Linux -- Ubuntu 20.04+ / Debian 11+ / RHEL 8+ (x64) -- SQL Server 2019+ for Linux (installer can set it up) +Full details: **[Security docs](https://docs.dispatchrelay.app/security/)**. Please report vulnerabilities privately by email to **security@dispatchrelay.app** rather than in public. ---- +## Building from source -## Building from Source +Prerequisites: the **.NET 10 SDK**, **Node.js 20+**, and **Docker** (for SQL). ```bash -# Clone -git clone https://github.com/chrismuench/Dispatch-SMTP-Relay.git -cd dispatch - -# Build the React UI -cd src/Dispatch.UI -npm install -npm run build -cd ../.. - -# Build and run -dotnet run --project src/Dispatch.Service - -# Run tests -dotnet test +git clone https://github.com/CinderHillsDev/dispatch-platform.git +cd dispatch-platform +docker compose up -d # SQL (Azure SQL Edge); schema auto-created on first run +cd src/Dispatch.UI && npm install && npm run build && cd ../.. +rm -rf src/Dispatch.Web/wwwroot && mkdir -p src/Dispatch.Web/wwwroot +cp -r src/Dispatch.UI/dist/* src/Dispatch.Web/wwwroot/ +ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/Dispatch.Service ``` -The web UI is served at `http://localhost:8080` and the SMTP listener starts on ports 25 and 587. +`appsettings.Development.json` (git-ignored) needs at least the SQL connection string and an `AdminPassword`. Tests: `dotnet test` (Data integration tests run only when `DISPATCH_TEST_SQL` is set, auto-skipped otherwise). The documentation site lives in its own repo (`dispatch-docs`, published at [docs.dispatchrelay.app](https://docs.dispatchrelay.app/)); the marketing site is `dispatch-website`. -> **Note:** You need SQL Server Express (or any SQL Server instance) running locally and a connection string in `src/Dispatch.Service/appsettings.Development.json` before the service will start. +## Adding a provider ---- - -## Project Structure - -``` -dispatch/ - src/ - Dispatch.Core/ # SMTP listener, relay pipeline, queue, models, purge - Dispatch.Web/ # ASP.NET Core host, REST API, SignalR hub - Dispatch.UI/ # React + Vite SPA (embedded in Dispatch.Web at build time) - Dispatch.Providers/ # Mailgun, SendGrid, Azure, SMTP provider implementations - Dispatch.Service/ # Entry point and DI wiring - installer/ - windows/ # WiX v5 MSI source - linux/ # systemd unit template and install.sh - tests/ - Dispatch.Core.Tests/ - Dispatch.Web.Tests/ - Dispatch.Integration.Tests/ - docs/ - SPEC.md # Full technical specification -``` - ---- - -## Upgrading - -Run the new `DispatchSetup-{version}-x64.exe` (Windows) or re-run `install.sh` (Linux). The installer: - -1. Detects the existing version automatically -2. Drains the in-flight queue (waits up to 60 s for processing messages to complete) -3. Stops the service -4. Applies any new database schema migrations (additive only — no data loss) -5. Replaces the binary -6. Restarts the service - -Configuration and all message history are preserved across upgrades. - ---- - -## Contributing - -Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR. - -**Good first issues:** provider implementations, UI improvements, documentation. See the [issue tracker](https://github.com/chrismuench/Dispatch-SMTP-Relay/issues?q=label%3A%22good+first+issue%22). - -**Adding a provider:** implement `IRelayProvider` in `Dispatch.Providers`, add the settings model, add the UI fields in the provider settings page, add tests. See `SendGridProvider.cs` as the reference implementation. - ---- - -## Security - -The admin web UI is **HTTPS-only** — the bootstrap generates a self-signed certificate if you don't have one. The HTTP ingestion API supports both HTTP and HTTPS, since many internal devices (printers, scanners, legacy apps) can't do HTTPS. All listeners enforce access via configurable IP/CIDR allow-lists at the application layer; denied connections are logged with the source IP. Credentials are stored AES-256 encrypted (API keys, provider secrets) or bcrypt-hashed (SMTP sender passwords) in SQL Server. If you find a security issue please report it privately via [GitHub Security Advisories](https://github.com/chrismuench/Dispatch-SMTP-Relay/security/advisories/new) rather than a public issue. - ---- +Implement `IRelayProvider` in `Dispatch.Providers` (see `SendGridProvider.cs`), wire it into `RelayProviderFactory`, then add the UI fields and tests. ## Licence -AGPL-3.0 with Commons Clause — see [LICENSE](LICENSE). - -**What you can do:** use Dispatch internally, self-host it, modify it, contribute back. - -**What you cannot do:** sell Dispatch, charge for access to a hosted version of it, or distribute it as part of a paid product. - -The Commons Clause prevents anyone from taking this code and charging money for the binary or a hosted service. See the [licence FAQ](docs/licence-faq.md) for common scenarios. +Dispatch is free and open source software, released under the **[Apache License 2.0](LICENSE)**. © 2026 CinderHills. You are free to use, modify, and redistribute it, including commercially; no license key is required to run it. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..64266ace --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,50 @@ +# Security Policy + +Dispatch SMTP Relay handles mail and provider credentials, so security reports are taken seriously and +very welcome. + +## Reporting a vulnerability + +**Please report privately - do not open a public issue for a vulnerability.** + +Use **[GitHub Security Advisories](https://github.com/chrismuench/Dispatch-SMTP-Relay/security/advisories/new)** +(Security → Report a vulnerability). That keeps the report private until a fix is available and lets us +collaborate on it directly. + +If you can, include: + +- A description of the issue and its impact +- Steps to reproduce (a minimal proof of concept is ideal) +- Affected version / commit, and your environment (OS, deployment shape - installer, Docker, etc.) +- Any suggested remediation + +Please give us a reasonable window to release a fix before public disclosure. We'll acknowledge your +report, keep you updated on progress, and credit you in the advisory unless you'd prefer to stay anonymous. + +## Supported versions + +Dispatch is pre-1.0 and moving quickly. Security fixes target the **latest release** and the default +branch. Please reproduce on the latest version before reporting. + +## Scope + +In scope - anything that lets an attacker: + +- Send mail through Dispatch without authorization (open-relay / allow-list bypass / SMTP AUTH bypass) +- Read or exfiltrate message content, the spool, provider credentials, or API keys +- Bypass the dashboard login, API-key auth, or the source-IP allow-lists +- Escalate privileges via the installers/service, or inject SQL / commands +- Cause persistent denial of service + +Generally out of scope: findings that require an already-compromised host or physical access; missing +hardening headers with no demonstrated impact; volumetric DoS; and social-engineering reports. + +## Where security controls live (for reviewers) + +- Source-IP allow-lists: `WebAuthMiddleware`, `ApiKeyMiddleware`, `CidrMailboxFilter` +- Auth & throttling: `AuthEndpoints` + `LoginThrottle` (dashboard), `ConfiguredUserAuthenticator` + + `SmtpAuthThrottle` (SMTP AUTH), `SqlApiKeyRepository` (bcrypt, constant-time verify) +- Credential encryption at rest: `SecureConfig` (AES-256-GCM on Linux/macOS, DPAPI on Windows) +- Transport: `WebUi:TlsCertPath` enables dashboard HTTPS + +See [`docs/SPEC.md` §17](docs/SPEC.md) for the full security model. diff --git a/appliance/Import-DispatchAppliance.Tests.ps1 b/appliance/Import-DispatchAppliance.Tests.ps1 new file mode 100644 index 00000000..09fad4de --- /dev/null +++ b/appliance/Import-DispatchAppliance.Tests.ps1 @@ -0,0 +1,71 @@ +# Pester tests for the Hyper-V import helper. The real Hyper-V cmdlets need the Hyper-V role (absent on CI +# runners), so we declare stub functions for them and Pester-Mock those - validating the script's LOGIC +# (folder layout, VLAN tagging, Secure Boot template, validation/guards) without an actual hypervisor. +# Run on windows-latest, where the runner is an Administrator (so the script's access check passes) and the +# Windows identity APIs work. This is NOT a substitute for a live import - it's the part we can automate. + +BeforeAll { + $script:ScriptPath = Join-Path $PSScriptRoot 'Import-DispatchAppliance.ps1' + + # Stub the Hyper-V cmdlets (with the parameters the script actually passes) so Mock can intercept them. + function Get-VM { [CmdletBinding()] param($Name) } + function Get-VMSwitch { [CmdletBinding()] param($Name) } + function Get-VMHost { [CmdletBinding()] param() } + function New-VM { [CmdletBinding()] param($Name, $Generation, $MemoryStartupBytes, $VHDPath, $SwitchName, $Path) } + function Set-VMProcessor { [CmdletBinding()] param($VM, $Count) } + function Set-VMFirmware { [CmdletBinding()] param($VM, $EnableSecureBoot, $SecureBootTemplate) } + function Set-VMMemory { [CmdletBinding()] param($VM, $DynamicMemoryEnabled) } + function Set-VMNetworkAdapterVlan { [CmdletBinding()] param($VMName, [switch]$Access, $VlanId) } + function Start-VM { [CmdletBinding()] param($VM, $Name) } +} + +Describe 'Import-DispatchAppliance (unattended)' { + BeforeEach { + $script:Vhdx = (New-Item -ItemType File -Path (Join-Path $TestDrive 'dispatch-appliance.vhdx') -Force).FullName + $script:Store = Join-Path $TestDrive 'store' + + Mock Get-VM { $null } # VM does not already exist + Mock Get-VMSwitch { [pscustomobject]@{ Name = 'External'; SwitchType = 'External' } } + Mock Get-VMHost { [pscustomobject]@{ VirtualMachinePath = 'C:\HyperV' } } + Mock New-VM { [pscustomobject]@{ Name = $Name } } + Mock Set-VMProcessor {} + Mock Set-VMFirmware {} + Mock Set-VMMemory {} + Mock Set-VMNetworkAdapterVlan {} + Mock Start-VM {} + Mock Copy-Item {} + } + + It 'keeps VM config + disk together under \' { + & $ScriptPath -VhdxPath $Vhdx -Name 'TestVM' -SwitchName 'External' -VmPath $Store + $vmDir = Join-Path $Store 'TestVM' + Should -Invoke New-VM -Times 1 -ParameterFilter { $Path -eq $vmDir } + Should -Invoke Copy-Item -Times 1 -ParameterFilter { $Destination -eq (Join-Path $vmDir 'dispatch-appliance.vhdx') } + } + + It 'tags the adapter with the VLAN when one is given' { + & $ScriptPath -VhdxPath $Vhdx -Name 'TestVM' -SwitchName 'External' -VmPath $Store -VlanId 20 + Should -Invoke Set-VMNetworkAdapterVlan -Times 1 -ParameterFilter { $VlanId -eq 20 -and $Access } + } + + It 'leaves the adapter untagged when no VLAN is given' { + & $ScriptPath -VhdxPath $Vhdx -Name 'TestVM' -SwitchName 'External' -VmPath $Store + Should -Invoke Set-VMNetworkAdapterVlan -Times 0 + } + + It 'sets the Linux Secure Boot template and disables Dynamic Memory' { + & $ScriptPath -VhdxPath $Vhdx -Name 'TestVM' -SwitchName 'External' -VmPath $Store + Should -Invoke Set-VMFirmware -ParameterFilter { $SecureBootTemplate -eq 'MicrosoftUEFICertificateAuthority' } + Should -Invoke Set-VMMemory -ParameterFilter { $DynamicMemoryEnabled -eq $false } + } + + It 'fails clearly when the named switch does not exist' { + Mock Get-VMSwitch { $null } -ParameterFilter { $Name -eq 'Nope' } + { & $ScriptPath -VhdxPath $Vhdx -Name 'TestVM' -SwitchName 'Nope' -VmPath $Store } | Should -Throw '*switch*not found*' + } + + It 'fails when a VM of that name already exists' { + Mock Get-VM { [pscustomobject]@{ Name = 'TestVM' } } + { & $ScriptPath -VhdxPath $Vhdx -Name 'TestVM' -SwitchName 'External' -VmPath $Store } | Should -Throw '*already exists*' + } +} diff --git a/appliance/Import-DispatchAppliance.ps1 b/appliance/Import-DispatchAppliance.ps1 new file mode 100644 index 00000000..15b0a784 --- /dev/null +++ b/appliance/Import-DispatchAppliance.ps1 @@ -0,0 +1,218 @@ +<# +.SYNOPSIS + Import the Dispatch SMTP Relay appliance VHDX as a ready-to-run Hyper-V VM. + +.DESCRIPTION + Creates a Generation 2 VM, copies the appliance VHDX into the chosen storage location, sets the Secure + Boot template to the Microsoft UEFI Certificate Authority (required for Linux), connects a virtual + switch (optionally on a specific VLAN), and (optionally) starts it. The appliance configures SQL Server + + Dispatch on first boot; browse to https://:8420 and set the admin password. + + Run it with no networking/storage flags for a guided menu: it lists the host's virtual switches and + storage volumes and prompts for the VLAN, memory, and CPU. Pass -SwitchName for fully unattended use. + + On a failover-cluster host it detects the cluster and offers to make the VM highly available (default + yes); unattended it adds the VM to the cluster automatically unless -NoCluster is passed. HA requires + the VM's storage to be on cluster shared storage (CSV). + +.EXAMPLE + # Guided menu - finds the .vhdx next to this script automatically: + .\Import-DispatchAppliance.ps1 + +.EXAMPLE + # Unattended (pass -SwitchName to skip the menu; -VhdxPath only if it's not next to the script): + .\Import-DispatchAppliance.ps1 -Name "Dispatch" -MemoryGB 6 -SwitchName "External" -VlanId 20 -VmPath "D:\Hyper-V" -Start +#> +[CmdletBinding()] +param( + # Optional: defaults to the .vhdx sitting next to this script (the zip ships them together). + [string] $VhdxPath, + [string] $Name = "Dispatch SMTP Relay", + [int] $MemoryGB = 4, + [int] $CpuCount = 2, + [string] $SwitchName, + [ValidateRange(0, 4094)] [int] $VlanId = 0, # 0 = untagged / no VLAN + [string] $VmPath, + [switch] $Interactive, + [switch] $NoCluster, # skip making the VM highly available even on a failover-cluster host + [switch] $Start +) + +$ErrorActionPreference = "Stop" + +# --- helpers ------------------------------------------------------------------------------------ +function Read-WithDefault([string]$Prompt, [string]$Default) { + $v = Read-Host "$Prompt [$Default]" + if ([string]::IsNullOrWhiteSpace($v)) { return $Default } else { return $v } +} + +function Read-IntWithDefault([string]$Prompt, [int]$Default) { + while ($true) { + $v = Read-Host "$Prompt [$Default]" + if ([string]::IsNullOrWhiteSpace($v)) { return $Default } + if ($v -match '^\d+$' -and [int]$v -gt 0) { return [int]$v } + Write-Host " Enter a positive whole number." + } +} + +function Select-VmSwitch { + $switches = @(Get-VMSwitch -ErrorAction SilentlyContinue | Sort-Object Name) + if (-not $switches) { throw "No Hyper-V virtual switches found. Create one in Hyper-V Manager, or pass -SwitchName." } + Write-Host "" + Write-Host "Available virtual switches:" + for ($i = 0; $i -lt $switches.Count; $i++) { + $s = $switches[$i] + $extra = if ($s.SwitchType -eq 'External' -and $s.NetAdapterInterfaceDescription) { " - $($s.NetAdapterInterfaceDescription)" } else { "" } + Write-Host (" [{0}] {1} ({2}){3}" -f ($i + 1), $s.Name, $s.SwitchType, $extra) + } + while ($true) { + $sel = Read-Host "Select a switch by number" + if ($sel -match '^\d+$' -and [int]$sel -ge 1 -and [int]$sel -le $switches.Count) { return $switches[[int]$sel - 1].Name } + Write-Host " Enter a number between 1 and $($switches.Count)." + } +} + +function Read-VlanId { + while ($true) { + $v = Read-Host "VLAN ID (1-4094, blank = none/untagged)" + if ([string]::IsNullOrWhiteSpace($v)) { return 0 } + if ($v -match '^\d+$' -and [int]$v -ge 1 -and [int]$v -le 4094) { return [int]$v } + Write-Host " Enter a VLAN ID between 1 and 4094, or leave blank for none." + } +} + +function Select-Storage([string]$Default) { + Write-Host "" + Write-Host "Storage volumes:" + Get-Volume -ErrorAction SilentlyContinue | + Where-Object { $_.DriveLetter -and $_.Size -gt 0 } | Sort-Object DriveLetter | ForEach-Object { + Write-Host (" {0}: {1:N0} GB free of {2:N0} GB {3}" -f $_.DriveLetter, ($_.SizeRemaining / 1GB), ($_.Size / 1GB), $_.FileSystemLabel) + } + Write-Host " (The appliance disk is ~6-10 GB thin-provisioned; SQL + logs grow it over time.)" + return (Read-WithDefault "VM storage folder" $Default) +} + +# --- preflight ---------------------------------------------------------------------------------- +# Managing Hyper-V needs either an elevated Administrator session or membership in the local "Hyper-V +# Administrators" group (well-known SID S-1-5-32-578) - the latter can run the cmdlets WITHOUT elevation. +$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) +$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator) +$isHyperVAdm = $principal.IsInRole([Security.Principal.SecurityIdentifier]::new("S-1-5-32-578")) +if (-not ($isAdmin -or $isHyperVAdm)) { + throw "This needs an elevated Administrator session or membership in the 'Hyper-V Administrators' group. Re-run as administrator, or have an admin add you to that group." +} + +# Default to the .vhdx shipped alongside this script (the appliance zip contains both). Only one is expected. +if (-not $VhdxPath) { + $found = @(Get-ChildItem -LiteralPath $PSScriptRoot -Filter *.vhdx -File -ErrorAction SilentlyContinue) + if ($found.Count -eq 1) { $VhdxPath = $found[0].FullName; Write-Host "Using VHDX: $VhdxPath" } + elseif ($found.Count -eq 0) { throw "No .vhdx found next to this script. Unzip dispatch-appliance.vhdx.zip here first, or pass -VhdxPath." } + else { throw "Multiple .vhdx files found next to this script; pass -VhdxPath to pick one." } +} +if (-not (Test-Path -LiteralPath $VhdxPath -PathType Leaf)) { throw "VHDX file not found: $VhdxPath" } +$VhdxPath = (Resolve-Path -LiteralPath $VhdxPath).Path +if ([System.IO.Path]::GetExtension($VhdxPath) -notin @('.vhdx', '.vhd')) { + throw "-VhdxPath must point at the appliance .vhdx file, but got '$VhdxPath'. Unzip dispatch-appliance.vhdx.zip and pass the dispatch-appliance.vhdx inside it." +} + +# Detect a failover cluster on this host (Get-Cluster ships in the FailoverClusters module, present only on +# cluster nodes). When clustered, offer to make the VM highly available - defaulting to yes. +$inCluster = $false +if (Get-Command Get-Cluster -ErrorAction SilentlyContinue) { + try { $inCluster = [bool](Get-Cluster -ErrorAction Stop) } catch { $inCluster = $false } +} +$AddToCluster = $false + +# Guided menu when no switch was specified (or -Interactive): collect name, switch, VLAN, storage, sizing. +$useMenu = $Interactive -or (-not $SwitchName) +if ($useMenu) { + Write-Host "=== Dispatch SMTP Relay - Hyper-V import ===" + $Name = Read-WithDefault "VM name" $Name + $SwitchName = Select-VmSwitch + $VlanId = Read-VlanId + $VmPath = Select-Storage ($(if ($VmPath) { $VmPath } else { (Get-VMHost).VirtualMachinePath })) + $MemoryGB = Read-IntWithDefault "Memory (GB)" $MemoryGB + $CpuCount = Read-IntWithDefault "vCPU count" $CpuCount + if (-not $Start) { $Start = (Read-WithDefault "Start the VM after import? (y/N)" "N") -match '^(y|yes)$' } + if ($inCluster -and -not $NoCluster) { + $AddToCluster = (Read-WithDefault "This host is in a failover cluster - make the VM highly available? (Y/n)" "Y") -match '^(y|yes)$' + } +} +else { + Write-Host "Using network switch: $SwitchName" + $AddToCluster = $inCluster -and (-not $NoCluster) # unattended: HA by default on a cluster (use -NoCluster to skip) +} + +if (Get-VM -Name $Name -ErrorAction SilentlyContinue) { throw "A VM named '$Name' already exists." } +if (-not (Get-VMSwitch -Name $SwitchName -ErrorAction SilentlyContinue)) { throw "Virtual switch '$SwitchName' not found." } +if (-not $VmPath) { $VmPath = (Get-VMHost).VirtualMachinePath } + +# Confirm the plan. +Write-Host "" +Write-Host "About to create:" +Write-Host (" Name: {0}" -f $Name) +Write-Host (" Sizing: {0} vCPU, {1} GB RAM (Gen2, Dynamic Memory off)" -f $CpuCount, $MemoryGB) +Write-Host (" Switch: {0}{1}" -f $SwitchName, $(if ($VlanId -gt 0) { " (VLAN $VlanId)" } else { " (untagged)" })) +Write-Host (" Storage: {0}" -f (Join-Path $VmPath $Name)) +Write-Host (" HA: {0}" -f $(if ($AddToCluster) { "add to the failover cluster" } else { "standalone" })) +if ($useMenu) { + if ((Read-WithDefault "Proceed? (Y/n)" "Y") -notmatch '^(y|yes)$') { Write-Host "Cancelled."; return } +} + +# --- create ------------------------------------------------------------------------------------- +# Everything for this VM lives under one folder named after it: \\ - holding both +# the VM config (New-VM -Path) and the copied VHDX. This keeps each VM self-contained (matching the Hyper-V +# wizard's "store the VM in a different location") and the original appliance VHDX stays pristine. +$destDir = Join-Path $VmPath $Name +New-Item -ItemType Directory -Force -Path $destDir | Out-Null +$destVhdx = Join-Path $destDir ([IO.Path]::GetFileName($VhdxPath)) +Write-Host "Copying VHDX -> $destVhdx" +Copy-Item -LiteralPath $VhdxPath -Destination $destVhdx -Force +if (-not (Test-Path -LiteralPath $destVhdx -PathType Leaf)) { throw "VHDX copy failed (destination is not a file): $destVhdx" } + +Write-Host "Creating Generation 2 VM '$Name'" +try { + $vm = New-VM -Name $Name -Generation 2 -MemoryStartupBytes ($MemoryGB * 1GB) ` + -VHDPath $destVhdx -SwitchName $SwitchName -Path $destDir + Set-VMProcessor -VM $vm -Count $CpuCount + # Linux on Gen2 needs the Microsoft UEFI CA Secure Boot template (not the default Windows one). + Set-VMFirmware -VM $vm -EnableSecureBoot On -SecureBootTemplate "MicrosoftUEFICertificateAuthority" + # SQL Server needs a stable working set; disable Dynamic Memory. + Set-VMMemory -VM $vm -DynamicMemoryEnabled $false + # Apply a VLAN tag to the adapter if requested (access mode = single tagged VLAN). + if ($VlanId -gt 0) { + Set-VMNetworkAdapterVlan -VMName $Name -Access -VlanId $VlanId + Write-Host "Network adapter set to VLAN access mode, VLAN ID $VlanId." + } +} +catch { + # Roll back a half-created VM + the copied files so the import can be retried cleanly. + Write-Warning "Import failed; cleaning up the partial VM and copied files." + Get-VM -Name $Name -ErrorAction SilentlyContinue | Remove-VM -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $destDir -Recurse -Force -ErrorAction SilentlyContinue + throw +} + +# Make it highly available if requested. Best-effort: a failure here (e.g. storage isn't on a CSV) leaves a +# working standalone VM rather than rolling it back. (Requires the VM's storage to be on cluster shared storage.) +if ($AddToCluster) { + Write-Host "Adding '$Name' to the failover cluster (highly available)..." + try { + Add-ClusterVirtualMachineRole -VirtualMachine $Name -ErrorAction Stop | Out-Null + Write-Host "Added to the failover cluster." + } catch { + Write-Warning "Could not add the VM to the cluster: $($_.Exception.Message)" + Write-Warning "The VM is created and usable. Add it manually once its storage is on a CSV: Add-ClusterVirtualMachineRole -VirtualMachine '$Name'" + } +} + +Write-Host "" +Write-Host ("Created '{0}' (Gen2, {1} vCPU, {2} GB, switch '{3}'{4}{5})." -f ` + $Name, $CpuCount, $MemoryGB, $SwitchName, $(if ($VlanId -gt 0) { ", VLAN $VlanId" } else { "" }), $(if ($AddToCluster) { ", clustered" } else { "" })) +if ($Start) { + Start-VM -VM $vm + Write-Host "Started. First boot configures SQL + Dispatch (a few minutes)." +} else { + Write-Host "Start it with: Start-VM -Name '$Name'" +} +Write-Host "Then browse to https://:8420 and set the admin password." diff --git a/appliance/README.md b/appliance/README.md new file mode 100644 index 00000000..f2604b6c --- /dev/null +++ b/appliance/README.md @@ -0,0 +1,116 @@ +# Dispatch SMTP Relay - virtual appliance + +A ready-to-run **Ubuntu 24.04 LTS** virtual machine with Dispatch and SQL Server Express pre-installed. Import it, power it on, and the dashboard comes up - no .NET, SQL, or command line needed. + +There's a **separate download per hypervisor** - each is a single zip (unzip once) with that format's image, its README, and the import helper: + +| Hypervisor | Download (one zip) | Contains | +|---|---|---| +| **Hyper-V** | `dispatch-appliance-hyperv` | `dispatch-appliance.vhdx` + `Import-DispatchAppliance.ps1` + README | +| **VMware** (vSphere/ESXi/Workstation/Fusion) | `dispatch-appliance-vmware` | `dispatch-appliance.ova` + README | +| **KVM/libvirt & Proxmox** | `dispatch-appliance-kvm` | `dispatch-appliance.qcow2` + `import-libvirt.sh` / `import-proxmox.sh` + README | + +All are **Gen2/UEFI**, ~4 GB RAM recommended (SQL Server needs ~2 GB). They boot on DHCP, but a relay your apps point at should have a **static IP** - set one after first boot (see [Static IP](#static-ip)). + +## Hyper-V + +**Guided menu** (elevated **Administrator**, or a member of the **Hyper-V Administrators** group - the script checks for one), after unzipping the `.vhdx`: +```powershell +.\Import-DispatchAppliance.ps1 +``` +Run with no networking flags, it walks you through it: it lists the host's **virtual switches** to pick from, your **storage volumes** (with free space) to choose where the VM lives, an optional **VLAN ID**, and memory/CPU - then confirms before creating. + +**Unattended** (pass `-SwitchName` to skip the menu): +```powershell +.\Import-DispatchAppliance.ps1 -SwitchName "External" -VlanId 20 -VmPath "D:\Hyper-V" -MemoryGB 6 -Start +``` +Either way it creates a Gen2 VM, sets the **Microsoft UEFI CA** Secure Boot template (required for Linux), disables Dynamic Memory, attaches the switch (tagging the VLAN if given), and optionally starts it. + +**Or manually:** Hyper-V Manager → New → VM → **Generation 2**, 4096 MB, a DHCP switch (e.g. *Default Switch*), **Use an existing virtual hard disk** → the unzipped `.vhdx`; then Settings → Security → Secure Boot → template **Microsoft UEFI Certificate Authority**. + +## VMware (vSphere / ESXi / Workstation / Fusion) + +Deploy the OVA: vSphere Client → **Deploy OVF Template** → select `dispatch-appliance--x64.ova` (or `File → Open` in Workstation/Fusion). The descriptor sets EFI firmware and a 64-bit Ubuntu guest; accept the defaults. (You can switch the SCSI/NIC to PVSCSI/VMXNET3 after import for performance - the image has the in-kernel drivers.) + +## KVM / libvirt + +```bash +sudo ./import-libvirt.sh dispatch-appliance.qcow2 --start +``` +Creates a UEFI VM via `virt-install --import` on the `default` network. Find the IP with `virsh domifaddr dispatch`. + +## Proxmox VE + +Copy the `.qcow2` to the Proxmox host and run **as root** (`qm` requires it), then (pick an unused VMID, e.g. 9000): +```bash +./import-proxmox.sh dispatch-appliance.qcow2 9000 --storage local-lvm --bridge vmbr0 --start +``` +Creates a q35/OVMF VM, imports the disk as `scsi0`, and sets it to boot. + +## First boot + +On first start the appliance gives itself a **unique** SQL SA password, configures and starts SQL Server Express, and starts Dispatch (the database and schema are created automatically). This takes a couple of minutes the first time. + +Then browse to the dashboard: + +``` +https://:8420 +``` + +(The VM gets its IP via DHCP - see it in Hyper-V Manager or with `ip a` on the console.) It's a self-signed certificate, so accept the browser warning. **Set the admin password on the first login.** + +Default ports: SMTP **25** and **587**, ingestion API **8025**, dashboard **8420** - change them in the dashboard. (The appliance binds the standard SMTP ports out of the box; it falls back to **2525** only if 25 is somehow already in use.) + +## Logins (there are two) + +| | Username | Password | Change it | +|---|---|---|---| +| **OS** (console / SSH) | `ubuntu` | `dispatch` | forced on first login; later `passwd` | +| **Dashboard** (web UI) | - (single admin) | set on first visit to `:8420` | **System → About → Change password** | + +SSH is enabled with password auth (`ssh ubuntu@`). The dashboard password is stored only as a bcrypt hash. + +## Static IP + +It boots on DHCP, but a relay your apps point at should have a fixed address - set one early. A baked-in helper, **`dispatch-set-ip`**, pins an address for you - it auto-detects the NIC, writes the netplan file (mode 600), applies it, and prints the new dashboard URL: + +```bash +# interactive - just answer the prompts: +sudo dispatch-set-ip + +# or in one line (DNS defaults to 1.1.1.1,8.8.8.8): +sudo dispatch-set-ip -a 192.168.1.50/24 -g 192.168.1.1 -d 1.1.1.1,8.8.8.8 + +# revert to automatic: +sudo dispatch-set-ip --dhcp +``` + +Pass `-i ` to target a specific interface (see `ip a`); run `dispatch-set-ip -h` for all options. + +## Maintenance (console) + +- Service: `systemctl status dispatch` · logs: `journalctl -u dispatch -f` and `/var/log/dispatch/`. +- SQL: `systemctl status mssql-server`. +- Config (connection string only): `/var/lib/dispatch/appsettings.json` - everything else is in the dashboard. + +## Security notes + +- Every appliance generates its **own** SQL SA password, at-rest encryption key (`.dispatch-key`), dashboard TLS cert, SSH host keys, and machine-id on first boot - nothing secret is shared across downloads. +- The **OS login** (`ubuntu`/`dispatch`) is a known default but **must be changed on first login** - do so immediately, especially before exposing the VM beyond a trusted LAN. +- The dashboard admin password is set by **you** on first login and is stored only as a bcrypt hash in the database. +- The appliance bundles **SQL Server Express**, which is free; its use is governed by Microsoft's SQL Server Express license terms. + +## Building it yourself + +CI builds all three formats in `.github/workflows/appliance.yml`. Locally, on a Linux host with `libguestfs-tools` + `qemu-utils`: + +```bash +dotnet publish src/Dispatch.Service -c Release -r linux-x64 --self-contained true -o publish/linux +sudo PREBUILT_DIR="$PWD/publish/linux" VERSION=1.2.3 LIBGUESTFS_BACKEND=direct \ + OUT="$PWD/dispatch-appliance.vhdx" \ + OVA_OUT="$PWD/dispatch-appliance.ova" \ + QCOW2_OUT="$PWD/dispatch-appliance.qcow2" \ + ./appliance/build-appliance.sh +``` + +It customizes the official Ubuntu cloud image **offline** with libguestfs (no Hyper-V host / nested virt needed): one qcow2 is built, then emitted as VHDX (Hyper-V), a stream-optimized-VMDK OVA (VMware), and a compressed qcow2 (KVM/Proxmox). `OVA_OUT`/`QCOW2_OUT` are optional. See `build-appliance.sh`, `provision.sh` (in-guest build steps), and `firstboot.sh` (per-VM first-boot setup). CI boots the image under QEMU/UEFI to verify it reaches `/health`; the OVA's structure (OVF XML + manifest) is validated but its VMware import is a manual check. diff --git a/appliance/build-appliance.sh b/appliance/build-appliance.sh new file mode 100755 index 00000000..38226fbc --- /dev/null +++ b/appliance/build-appliance.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# +# Dispatch SMTP Relay - Hyper-V appliance builder (Ubuntu 24.04 LTS). +# +# Customizes the official Ubuntu cloud image offline with libguestfs (no Hyper-V host, no nested virt) and +# converts it to a Gen2/UEFI dynamic VHDX. SQL Server Express's binaries are baked in; each VM configures +# SQL with a unique SA password and starts Dispatch on first boot (see appliance/firstboot.sh). +# +# Requires (host): libguestfs-tools, qemu-utils, curl. +# Usage: +# PREBUILT_DIR=publish/linux VERSION=1.2.3 ./appliance/build-appliance.sh +# +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO="$(cd "$HERE/.." && pwd)" + +PREBUILT_DIR="${PREBUILT_DIR:?set PREBUILT_DIR to the self-contained linux-x64 publish dir (contains Dispatch.Service)}" +VERSION="${VERSION:-0.0.0-dev}" +DISK_SIZE="${DISK_SIZE:-20G}" +OUT="${OUT:-dispatch-appliance-${VERSION}-x64.vhdx}" +UBUNTU_IMG_URL="${UBUNTU_IMG_URL:-https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img}" + +[ -f "$PREBUILT_DIR/Dispatch.Service" ] || { echo "PREBUILT_DIR has no Dispatch.Service: $PREBUILT_DIR" >&2; exit 1; } + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +export LIBGUESTFS_BACKEND=direct + +echo "==> Downloading Ubuntu cloud image" +curl -fSL "$UBUNTU_IMG_URL" -o "$WORK/base.img" + +echo "==> Verifying the cloud image against Ubuntu's GPG-signed SHA256SUMS" +# The base image is the entire OS of every appliance, so verify it fail-closed: check the checksum file's +# detached signature against the PINNED Ubuntu Cloud Image signing key, then check the image hash against it. +img_base="$(dirname "$UBUNTU_IMG_URL")" +img_name="$(basename "$UBUNTU_IMG_URL")" +curl -fSL "$img_base/SHA256SUMS" -o "$WORK/SHA256SUMS" +curl -fSL "$img_base/SHA256SUMS.gpg" -o "$WORK/SHA256SUMS.gpg" +UBUNTU_CLOUD_FPR="D2EB44626FDDC30B513D5BB71A5D6C4C7DB87C81" # Ubuntu Cloud Image Builder signing key +export GNUPGHOME="$WORK/gnupg"; mkdir -p "$GNUPGHOME"; chmod 700 "$GNUPGHOME" +gpg --batch --keyserver hkps://keyserver.ubuntu.com --recv-keys "$UBUNTU_CLOUD_FPR" 2>/dev/null \ + || gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys "$UBUNTU_CLOUD_FPR" \ + || { echo "ERROR: could not fetch the Ubuntu cloud-image signing key $UBUNTU_CLOUD_FPR" >&2; exit 1; } +gpg --batch --status-fd 1 --verify "$WORK/SHA256SUMS.gpg" "$WORK/SHA256SUMS" 2>/dev/null \ + | grep -q "VALIDSIG .*$UBUNTU_CLOUD_FPR" \ + || { echo "ERROR: SHA256SUMS signature did not verify against the pinned Ubuntu key" >&2; exit 1; } +want="$(grep " [*]\?${img_name}\$" "$WORK/SHA256SUMS" | awk '{print $1}')" +got="$(sha256sum "$WORK/base.img" | awk '{print $1}')" +[ -n "$want" ] && [ "$want" = "$got" ] \ + || { echo "ERROR: cloud image checksum mismatch (want='$want' got='$got')" >&2; exit 1; } +echo "cloud image verified: $img_name ($got)" + +echo "==> Assembling the staging payload" +STAGE="$WORK/stage" +mkdir -p "$STAGE/bin" +cp -a "$PREBUILT_DIR/." "$STAGE/bin/" +cp "$REPO/installer/linux/install.sh" "$STAGE/install.sh" +cp "$REPO/installer/linux/dispatch.service" "$STAGE/dispatch.service" +# Web-UI updater files (so the appliance can apply uploaded upgrade packages); install.sh installs them. +cp "$REPO/installer/linux/dispatch-updater.service" "$STAGE/dispatch-updater.service" +cp "$REPO/installer/linux/dispatch-update.path" "$STAGE/dispatch-update.path" +cp "$REPO/installer/linux/dispatch-update.sh" "$STAGE/dispatch-update.sh" +cp "$REPO/src/Dispatch.Core/Updates/dispatch-update-public.pem" "$STAGE/dispatch-update-public.pem" +cp "$REPO/appliance/firstboot.sh" "$STAGE/firstboot.sh" +cp "$REPO/appliance/dispatch-firstboot.service" "$STAGE/dispatch-firstboot.service" +cp "$REPO/appliance/dispatch-set-ip" "$STAGE/dispatch-set-ip" +chmod +x "$STAGE/install.sh" "$STAGE/dispatch-update.sh" "$STAGE/firstboot.sh" "$STAGE/dispatch-set-ip" "$STAGE/bin/Dispatch.Service" + +echo "==> Detecting the guest kernel version (for Hyper-V integration tools, which are kernel-tied)" +# The Hyper-V KVP/VSS/fcopy daemons ship in a per-kernel package, so we must fetch the set matching THIS +# image's kernel (not the download container's). open-vm-tools / qemu-guest-agent are kernel-independent. +KVER="$(virt-ls -a "$WORK/base.img" /lib/modules 2>/dev/null | grep -E '^[0-9]' | sort -V | tail -1 || true)" +echo "guest kernel: ${KVER:-}" + +echo "==> Pre-downloading SQL Server Express + tools (.debs) for an offline in-guest install" +# Done on the host in a clean Ubuntu 24.04 container so the dependency closure matches the cloud image and +# the image build itself needs no in-guest network (libguestfs passt networking is unreliable on CI). +mkdir -p "$STAGE/debs" +docker run --rm -e KVER="$KVER" -v "$STAGE/debs:/debs" ubuntu:24.04 bash -ec ' + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq curl ca-certificates >/dev/null + # packages-microsoft-prod.deb sets up the Microsoft "prod" repo + signing key (msodbcsql18, mssql-tools18). + # Pin its SHA256 (fail-closed) so a swapped bootstrap .deb cannot install a rogue repo/key. If Microsoft + # rotates this .deb the build will fail here - re-verify the new file and update this hash. + curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb -o /tmp/pmc.deb + echo "c13f01ac7c3001b51a9281d40dde666db5e037e05512840c319832f7852bfec4 /tmp/pmc.deb" | sha256sum -c - \ + || { echo "ERROR: packages-microsoft-prod.deb checksum mismatch - refusing to trust it"; exit 1; } + dpkg -i /tmp/pmc.deb >/dev/null + # The SQL Server engine has its own per-version feed; Ubuntu 24.04 ships SQL Server 2025 (2022 is 22.04). + curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/mssql-server-2025.list \ + -o /etc/apt/sources.list.d/mssql-server.list + apt-get update -qq + cd /debs + # Full recursive runtime dependency closure of the target packages (skip virtual/undownloadable entries). + deps=$(apt-cache depends --recurse --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances \ + mssql-server mssql-tools18 unixodbc-dev | grep "^\w" | sort -u) + for d in $deps; do apt-get download "$d" 2>/dev/null || true; done + echo "downloaded $(ls -1 /debs/*.deb | wc -l) SQL packages" + ls /debs/mssql-server_*.deb >/dev/null # fail the build if the core package is missing + + # Hypervisor guest agents so each hypervisor manager can display the VM IP (installed offline in the guest; + # NO first-boot network, so this keeps the appliance no-call-home): open-vm-tools = VMware, qemu-guest-agent + # = KVM/libvirt/Proxmox (both kernel-independent), and the Hyper-V KVP/VSS/fcopy daemons via the per-kernel + # linux-cloud-tools set matching THIS image (linux-cloud-tools-common holds the systemd units). Best-effort: + # a missing Hyper-V set only drops Hyper-V IP reporting, it does not fail the build. + agents="open-vm-tools qemu-guest-agent linux-cloud-tools-common" + [ -n "$KVER" ] && agents="$agents linux-cloud-tools-$KVER" + gdeps=$(apt-cache depends --recurse --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances \ + $agents 2>/dev/null | grep "^\w" | sort -u || true) + for d in $gdeps; do apt-get download "$d" 2>/dev/null || true; done + ls /debs/open-vm-tools_*.deb >/dev/null 2>&1 || echo "WARN: open-vm-tools not downloaded (VMware IP display may be unavailable)" + ls /debs/qemu-guest-agent_*.deb >/dev/null 2>&1 || echo "WARN: qemu-guest-agent not downloaded (KVM/Proxmox IP display may be unavailable)" + ls /debs/linux-cloud-tools-*-generic_*.deb >/dev/null 2>&1 || echo "WARN: Hyper-V cloud-tools for kernel '"'"'$KVER'"'"' not downloaded (Hyper-V IP display may be unavailable)" + echo "total .debs staged: $(ls -1 /debs/*.deb | wc -l)" +' + +echo "==> Expanding the root partition into a ${DISK_SIZE} working image" +qemu-img create -f qcow2 "$WORK/disk.qcow2" "$DISK_SIZE" +virt-resize --expand /dev/sda1 "$WORK/base.img" "$WORK/disk.qcow2" + +echo "==> Customizing the image (SQL Server Express + Dispatch + first-boot)" +# --no-network: provisioning installs pre-downloaded .debs offline, so the appliance needs no in-guest +# network (and we avoid libguestfs's passt networking, which fails on CI runners). +virt-customize -a "$WORK/disk.qcow2" \ + --no-network \ + --hostname dispatch \ + --copy-in "$STAGE:/opt" \ + --run "$REPO/appliance/provision.sh" + +echo "==> Verifying the image (bootloader + key files present)" +echo "ESP /EFI/BOOT:"; virt-ls -a "$WORK/disk.qcow2" /boot/efi/EFI/BOOT 2>/dev/null || echo " (none!)" +echo "ESP /EFI/ubuntu:"; virt-ls -a "$WORK/disk.qcow2" /boot/efi/EFI/ubuntu 2>/dev/null || echo " (none!)" +virt-ls -a "$WORK/disk.qcow2" /boot/efi/EFI/BOOT 2>/dev/null | grep -qi '^BOOTX64.EFI$' \ + || { echo "ERROR: UEFI fallback bootloader \EFI\BOOT\BOOTX64.EFI missing - image would not boot on empty-NVRAM firmware" >&2; exit 1; } + +for unit in dispatch.service dispatch-firstboot.service; do + virt-ls -a "$WORK/disk.qcow2" /etc/systemd/system/multi-user.target.wants 2>/dev/null | grep -qx "$unit" \ + || { echo "ERROR: $unit is not enabled (no WantedBy symlink) - it would not start at boot" >&2; exit 1; } +done +echo "verified: bootloader fallback + dispatch/firstboot units enabled" + +# Informational: which hypervisor guest agents ended up enabled (best-effort - never fails the build). At +# least open-vm-tools + qemu-guest-agent should be present; the Hyper-V KVP daemon requires the kernel-tied +# cloud-tools set to have been available for this image's kernel. +enabled_wants="$(virt-ls -a "$WORK/disk.qcow2" /etc/systemd/system/multi-user.target.wants 2>/dev/null || true)" +for unit in hv-kvp-daemon.service open-vm-tools.service qemu-guest-agent.service; do + echo "$enabled_wants" | grep -qx "$unit" && echo "guest agent enabled: $unit" || echo "guest agent NOT enabled: $unit" +done + +echo "==> Converting to a Gen2/UEFI dynamic VHDX" +qemu-img convert -p -f qcow2 -O vhdx -o subformat=dynamic "$WORK/disk.qcow2" "$OUT" +echo "==> Built $OUT"; ls -lh "$OUT" + +# Optional: a compressed qcow2 for KVM/libvirt/Proxmox (the native format). Set QCOW2_OUT to enable. +if [ -n "${QCOW2_OUT:-}" ]; then + echo "==> Building the qcow2 (KVM/Proxmox): $QCOW2_OUT" + qemu-img convert -p -f qcow2 -O qcow2 -c "$WORK/disk.qcow2" "$QCOW2_OUT" + echo "==> Built $QCOW2_OUT"; ls -lh "$QCOW2_OUT" +fi + +# Optional: a VMware (vSphere/ESXi) OVA from the same image. Set OVA_OUT to enable. +if [ -n "${OVA_OUT:-}" ]; then + echo "==> Building the VMware OVA: $OVA_OUT" + ova_dir="$WORK/ova"; mkdir -p "$ova_dir" + vmdk="dispatch-appliance-disk1.vmdk"; ovf="dispatch-appliance.ovf"; mf="dispatch-appliance.mf" + + # Stream-optimized VMDK (the only VMDK subformat valid inside an OVA). + qemu-img convert -p -f qcow2 -O vmdk -o subformat=streamOptimized "$WORK/disk.qcow2" "$ova_dir/$vmdk" + + capacity="$(qemu-img info --output=json "$WORK/disk.qcow2" | sed -n 's/.*"virtual-size": *\([0-9]*\).*/\1/p' | head -1)" + vmdk_size="$(stat -c%s "$ova_dir/$vmdk")" + sed -e "s|@@VMDK_FILE@@|$vmdk|" -e "s|@@VMDK_FILE_SIZE@@|$vmdk_size|" \ + -e "s|@@DISK_CAPACITY@@|$capacity|" -e "s|@@VERSION@@|$VERSION|" \ + "$REPO/appliance/dispatch.ovf.template" > "$ova_dir/$ovf" + + # Manifest: SHA256 of the ovf then the vmdk (OVF spec format). + ( cd "$ova_dir" && printf 'SHA256(%s)= %s\n' "$ovf" "$(sha256sum "$ovf" | cut -d' ' -f1)" > "$mf" + printf 'SHA256(%s)= %s\n' "$vmdk" "$(sha256sum "$vmdk" | cut -d' ' -f1)" >> "$mf" ) + + # OVA = tar of ovf, then mf, then disk - in that order (required by the OVF spec / ovftool). + ( cd "$ova_dir" && tar -cf "$OVA_OUT" "$ovf" "$mf" "$vmdk" ) + echo "==> Built $OVA_OUT"; ls -lh "$OVA_OUT" +fi diff --git a/appliance/dispatch-firstboot.service b/appliance/dispatch-firstboot.service new file mode 100644 index 00000000..80ca8097 --- /dev/null +++ b/appliance/dispatch-firstboot.service @@ -0,0 +1,18 @@ +[Unit] +Description=Dispatch appliance first-boot setup (SQL Server + per-VM secrets) +# Run once; the script writes the marker when it succeeds. +ConditionPathExists=!/var/lib/dispatch/.appliance-firstboot-done +# Before the relay so SQL is configured and reachable before Dispatch's first start. +Before=dispatch.service +After=network.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/opt/dispatch-appliance/firstboot.sh +# Surface first-boot output on the serial console (and journal) so CI/operators can see what happened. +StandardOutput=journal+console +StandardError=journal+console + +[Install] +WantedBy=multi-user.target diff --git a/appliance/dispatch-set-ip b/appliance/dispatch-set-ip new file mode 100644 index 00000000..4d58bf00 --- /dev/null +++ b/appliance/dispatch-set-ip @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# dispatch-set-ip - set a static IP (or revert to DHCP) on the Dispatch appliance, the easy way. +# Auto-detects the primary network interface, writes a netplan file, and applies it. Baked into the +# appliance at /usr/local/sbin/dispatch-set-ip. +# +# Usage: +# sudo dispatch-set-ip # interactive prompts +# sudo dispatch-set-ip -a 192.168.1.50/24 -g 192.168.1.1 # static (DNS defaults to 1.1.1.1,8.8.8.8) +# sudo dispatch-set-ip -a 192.168.1.50/24 -g 192.168.1.1 -d 192.168.1.1,8.8.8.8 -i ens160 +# sudo dispatch-set-ip --dhcp # back to automatic (DHCP) +# +set -euo pipefail + +NETPLAN_FILE="/etc/netplan/99-dispatch.yaml" +ADDR=""; GW=""; DNS="1.1.1.1,8.8.8.8"; NIC=""; MODE="" + +usage() { sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; } + +while [ $# -gt 0 ]; do + case "$1" in + -a|--address) ADDR="$2"; MODE="static"; shift 2;; + -g|--gateway) GW="$2"; shift 2;; + -d|--dns) DNS="$2"; shift 2;; + -i|--nic|--interface) NIC="$2"; shift 2;; + --dhcp) MODE="dhcp"; shift;; + -h|--help) usage 0;; + *) echo "unknown option: $1" >&2; usage 1;; + esac +done + +[ "$(id -u)" = 0 ] || { echo "Please run with sudo." >&2; exit 1; } + +# Auto-detect the primary NIC: the one carrying the default route, else the first real ethernet. +if [ -z "$NIC" ]; then + NIC="$(ip -o route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" + [ -n "$NIC" ] || NIC="$(ip -o link show | awk -F': ' '$2!="lo"{print $2; exit}')" +fi +[ -n "$NIC" ] || { echo "Could not detect a network interface. Pass one with -i (see 'ip a')." >&2; exit 1; } +echo "Network interface: $NIC" + +# Interactive: ask for what wasn't given (only when we have a terminal and no mode chosen). +if [ -z "$MODE" ]; then + if [ ! -t 0 ]; then echo "No address given and not interactive. Use -a/-g or --dhcp." >&2; exit 1; fi + printf 'Static IP and prefix (e.g. 192.168.1.50/24), or blank for DHCP: '; read -r ADDR + if [ -z "$ADDR" ]; then MODE="dhcp"; else + MODE="static" + printf 'Gateway (e.g. 192.168.1.1): '; read -r GW + printf 'DNS servers [%s]: ' "$DNS"; read -r _dns; [ -n "$_dns" ] && DNS="$_dns" + fi +fi + +if [ "$MODE" = "dhcp" ]; then + cat > "$NETPLAN_FILE" <&2; exit 1; } + [ -n "$GW" ] || { echo "A gateway is required for a static IP (-g)." >&2; exit 1; } + dns_yaml="$(echo "$DNS" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sed 's/.*/ - &/')" + cat > "$NETPLAN_FILE" </dev/null | awk '{print $4}' | head -1)" +echo +echo "Done. $NIC is now: ${ip_now:-(no IPv4 yet - check link/DHCP)}" +echo "Dashboard: https://${ip_now%%/*}:8420" diff --git a/appliance/dispatch.ovf.template b/appliance/dispatch.ovf.template new file mode 100644 index 00000000..d9b09923 --- /dev/null +++ b/appliance/dispatch.ovf.template @@ -0,0 +1,89 @@ + + + + + + + + Virtual disk information + + + + The list of logical networks + + The network the appliance attaches to (DHCP) + + + + Dispatch SMTP Relay appliance + Dispatch SMTP Relay @@VERSION@@ + + The kind of guest operating system + Ubuntu Linux (64-bit) + + + Virtual hardware requirements + + Virtual Hardware Family + 0 + Dispatch-SMTP-Relay + vmx-14 + + + hertz * 10^6 + Number of Virtual CPUs + 2 virtual CPU(s) + 1 + 3 + 2 + + + byte * 2^20 + Memory Size + 4096 MB of memory + 2 + 4 + 4096 + + + 0 + SCSI Controller + SCSI Controller 0 + 3 + lsilogic + 6 + + + 0 + Hard Disk 1 + ovf:/disk/vmdisk1 + 4 + 3 + 17 + + + 1 + true + VM Network + E1000e ethernet adapter + Ethernet 1 + 5 + E1000e + 10 + + + + + diff --git a/appliance/firstboot.sh b/appliance/firstboot.sh new file mode 100755 index 00000000..8f356feb --- /dev/null +++ b/appliance/firstboot.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# +# Dispatch appliance - first-boot setup. Runs once (via dispatch-firstboot.service) before the Dispatch +# service: it gives this VM its own unique SQL Server SA password, configures + starts SQL Server Express, +# substitutes that password into the Dispatch connection string (a placeholder is baked into the image), and +# lets systemd start Dispatch (ordered After= this unit). The Dispatch service then creates the DispatchLog +# database and schema on first start. OS-level secrets (SSH host keys, machine-id) are regenerated by +# cloud-init / systemd; the at-rest encryption key (.dispatch-key) and dashboard TLS cert are generated by +# the service itself on first start - none of those are baked into the image, so every appliance is unique. +# +set -euo pipefail + +MARKER="/var/lib/dispatch/.appliance-firstboot-done" +APPSETTINGS="/var/lib/dispatch/appsettings.json" +SQLCMD="/opt/mssql-tools18/bin/sqlcmd" + +[ -e "$MARKER" ] && { echo "dispatch-firstboot: already done"; exit 0; } +log() { echo "dispatch-firstboot: $*"; } + +# 1. Unique SA password. Alnum (from a FINITE source so nothing gets SIGPIPE'd under pipefail) + a fixed +# complexity suffix → meets SQL's policy and contains no characters special to the shell, sed, or JSON. +SA_PW="$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | cut -c1-28)Aa1!" + +# 2. Configure SQL Server Express (EULA accepted) with that password, then enable + start it. +log "configuring SQL Server Express" +MSSQL_SA_PASSWORD="$SA_PW" MSSQL_PID="Express" ACCEPT_EULA="Y" /opt/mssql/bin/mssql-conf -n setup +systemctl enable --now mssql-server + +# 3. Wait for SQL to accept logins before pointing Dispatch at it. +log "waiting for SQL Server" +ready=0 +for _ in $(seq 1 60); do + if "$SQLCMD" -S localhost -U sa -P "$SA_PW" -C -No -Q "SELECT 1" >/dev/null 2>&1; then ready=1; break; fi + sleep 2 +done +[ "$ready" = 1 ] || { log "ERROR: SQL Server did not become ready"; exit 1; } + +# 4. Inject the generated SA password into the connection string (the image ships a __SA_PASSWORD__ placeholder). +log "finalizing the Dispatch connection string" +sed -i "s|__SA_PASSWORD__|${SA_PW}|" "$APPSETTINGS" +chown dispatch:dispatch "$APPSETTINGS" +chmod 600 "$APPSETTINGS" + +# 5. Done. systemd starts dispatch.service next (it is ordered After=/Requires= this unit). +touch "$MARKER" +log "first-boot complete" diff --git a/appliance/hyperv-README.txt b/appliance/hyperv-README.txt new file mode 100644 index 00000000..64ac7633 --- /dev/null +++ b/appliance/hyperv-README.txt @@ -0,0 +1,69 @@ +============================================================================== + Dispatch SMTP Relay - Hyper-V appliance +============================================================================== + +This zip contains: + * dispatch-appliance.vhdx the ready-to-run virtual disk (Linux, Gen2/UEFI) + * Import-DispatchAppliance.ps1 a one-step import helper for Hyper-V + * README.txt this file + +------------------------------------------------------------------------------ + Requirements +------------------------------------------------------------------------------ + * Windows with the Hyper-V role enabled. + * Run PowerShell either as an elevated Administrator, OR as a member of the + local "Hyper-V Administrators" group (which can manage Hyper-V without + elevation). The script checks for one of these. + +------------------------------------------------------------------------------ + Quick start (guided menu) +------------------------------------------------------------------------------ + 1. Unzip this file to a folder (the .vhdx and this script land together). + 2. Open PowerShell in that folder and run: + + .\Import-DispatchAppliance.ps1 + + It finds the dispatch-appliance.vhdx next to it automatically, then walks + you through it: + - pick one of the host's virtual switches, + - choose the storage volume/folder for the VM (shows free space), + - optionally set a VLAN ID, + - set memory and vCPU, + then it confirms and creates the VM. + + 3. If you didn't choose to start it during the import: + Start-VM -Name "Dispatch SMTP Relay" + + 4. Find the VM's IP (Hyper-V Manager, or `ip a` on the VM console - the VM + uses DHCP), then browse to: + https://:8420 + It uses a self-signed certificate, so accept the browser warning, and + SET THE ADMIN PASSWORD on the first login. + +------------------------------------------------------------------------------ + Unattended import +------------------------------------------------------------------------------ + Pass -SwitchName to skip the menu; add any of -VlanId / -VmPath / -MemoryGB / + -CpuCount / -Start: + + .\Import-DispatchAppliance.ps1 -SwitchName "External" -VlanId 20 -VmPath "D:\Hyper-V" -MemoryGB 6 -Start + +------------------------------------------------------------------------------ + Manual import (no script) +------------------------------------------------------------------------------ + Hyper-V Manager -> New -> Virtual Machine: + * Generation 2 + * Memory: 4096 MB (disable Dynamic Memory) + * Connect a virtual switch + * "Use an existing virtual hard disk" -> dispatch-appliance.vhdx + Then VM Settings -> Security -> Secure Boot -> Template: + "Microsoft UEFI Certificate Authority" (required for Linux) + (To tag a VLAN: VM Settings -> Network Adapter -> VLAN ID.) + +------------------------------------------------------------------------------ + Notes +------------------------------------------------------------------------------ + * First boot configures SQL Server Express + Dispatch; allow a few minutes. + * The appliance is self-contained - no separate .NET runtime needed. + * Full docs: https://chrismuench.github.io/Dispatch-SMTP-Relay/ +============================================================================== diff --git a/appliance/import-libvirt.sh b/appliance/import-libvirt.sh new file mode 100755 index 00000000..c396a992 --- /dev/null +++ b/appliance/import-libvirt.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# +# Import the Dispatch SMTP Relay appliance qcow2 into KVM/libvirt as a UEFI VM (virt-install --import). +# The appliance configures SQL Server + Dispatch on first boot; then browse to https://:8420. +# +# Usage: +# sudo ./import-libvirt.sh dispatch-appliance.qcow2 [--name dispatch] [--memory 4096] [--vcpus 2] +# [--network default] [--start] +# +set -euo pipefail + +QCOW2="${1:?usage: import-libvirt.sh [options]}"; shift || true +NAME="dispatch"; MEMORY=4096; VCPUS=2; NETWORK="default"; START=0 +while [ $# -gt 0 ]; do + case "$1" in + --name) NAME="$2"; shift 2;; + --memory) MEMORY="$2"; shift 2;; + --vcpus) VCPUS="$2"; shift 2;; + --network) NETWORK="$2"; shift 2;; + --start) START=1; shift;; + *) echo "unknown option: $1" >&2; exit 1;; + esac +done + +[ -f "$QCOW2" ] || { echo "qcow2 not found: $QCOW2" >&2; exit 1; } +# Needs root: copies into the system libvirt images pool (/var/lib/libvirt/images), chowns it, and defines a +# system VM (qemu:///system). Re-run with sudo. (libvirt-group users still can't write the system pool.) +[ "$(id -u)" = 0 ] || { echo "Run with sudo: $0 writes to /var/lib/libvirt/images and defines a system VM." >&2; exit 1; } +command -v virt-install >/dev/null || { echo "virt-install not found (install virtinst/virt-install)." >&2; exit 1; } + +# Copy into the default libvirt images pool so libvirt owns/labels it (SELinux/AppArmor friendly). +POOL="/var/lib/libvirt/images" +dest="$POOL/${NAME}.qcow2" +mkdir -p "$POOL" +echo "==> Copying $QCOW2 -> $dest" +cp -f "$QCOW2" "$dest" +command -v virt-customize >/dev/null && true +chown libvirt-qemu:kvm "$dest" 2>/dev/null || chown qemu:qemu "$dest" 2>/dev/null || true + +run_flag=""; [ "$START" = 1 ] || run_flag="--noreboot" +echo "==> Creating VM '$NAME' (UEFI, ${VCPUS} vCPU, ${MEMORY} MB, network '$NETWORK')" +virt-install \ + --name "$NAME" \ + --memory "$MEMORY" \ + --vcpus "$VCPUS" \ + --os-variant ubuntu24.04 \ + --boot uefi \ + --disk path="$dest",format=qcow2,bus=virtio \ + --network network="$NETWORK",model=virtio \ + --channel unix,target_type=virtio,name=org.qemu.guest_agent.0 \ + --graphics none --console pty,target_type=serial \ + --import --noautoconsole $run_flag +# --channel org.qemu.guest_agent.0 adds the guest-agent virtio channel; the appliance's baked-in +# qemu-guest-agent then reports the VM's IP to libvirt ("virsh domifaddr --source agent"). + +echo +echo "VM '$NAME' defined. First boot configures SQL + Dispatch (a few minutes)." +[ "$START" = 1 ] || echo "Start it with: virsh start $NAME" +echo "Find its IP with: virsh domifaddr $NAME" +echo "Then browse to https://:8420 and set the admin password." diff --git a/appliance/import-proxmox.sh b/appliance/import-proxmox.sh new file mode 100755 index 00000000..35d02e79 --- /dev/null +++ b/appliance/import-proxmox.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# Import the Dispatch SMTP Relay appliance qcow2 into Proxmox VE as a UEFI (OVMF) VM. Run on the Proxmox +# host. The appliance configures SQL Server + Dispatch on first boot; then browse to https://:8420. +# +# Usage: +# ./import-proxmox.sh dispatch-appliance.qcow2 [--storage local-lvm] [--bridge vmbr0] +# [--name dispatch] [--memory 4096] [--cores 2] [--start] +# +set -euo pipefail + +QCOW2="${1:?usage: import-proxmox.sh [options]}" +VMID="${2:?usage: import-proxmox.sh [options]}" +shift 2 || true +STORAGE="local-lvm"; BRIDGE="vmbr0"; NAME="dispatch"; MEMORY=4096; CORES=2; START=0 +while [ $# -gt 0 ]; do + case "$1" in + --storage) STORAGE="$2"; shift 2;; + --bridge) BRIDGE="$2"; shift 2;; + --name) NAME="$2"; shift 2;; + --memory) MEMORY="$2"; shift 2;; + --cores) CORES="$2"; shift 2;; + --start) START=1; shift;; + *) echo "unknown option: $1" >&2; exit 1;; + esac +done + +[ -f "$QCOW2" ] || { echo "qcow2 not found: $QCOW2" >&2; exit 1; } +[ "$(id -u)" = 0 ] || { echo "Run this on the Proxmox VE host as root - qm requires it. Try: sudo $0 ..." >&2; exit 1; } +command -v qm >/dev/null || { echo "qm not found - run this on a Proxmox VE host." >&2; exit 1; } +qm status "$VMID" >/dev/null 2>&1 && { echo "VMID $VMID already exists." >&2; exit 1; } + +echo "==> Creating VM $VMID ($NAME): q35 + OVMF/UEFI, ${CORES} cores, ${MEMORY} MB, bridge $BRIDGE" +qm create "$VMID" --name "$NAME" --memory "$MEMORY" --cores "$CORES" \ + --machine q35 --bios ovmf --scsihw virtio-scsi-pci \ + --net0 "virtio,bridge=$BRIDGE" \ + --agent enabled=1 \ + --efidisk0 "$STORAGE:0,efitype=4m,pre-enrolled-keys=0" +# --agent enabled=1 adds the QEMU guest-agent channel; the appliance's baked-in qemu-guest-agent then +# reports the VM's IP (and enables graceful shutdown) in the Proxmox UI. + +echo "==> Importing the appliance disk into $STORAGE" +qm importdisk "$VMID" "$QCOW2" "$STORAGE" + +# importdisk attaches it as an unused disk; wire it up as scsi0 and boot from it. +disk="$(qm config "$VMID" | sed -n 's/^unused0: *//p')" +[ -n "$disk" ] || { echo "ERROR: could not find the imported disk (unused0) on VM $VMID" >&2; exit 1; } +echo "==> Attaching $disk as scsi0 and setting boot order" +qm set "$VMID" --scsi0 "$disk" +qm set "$VMID" --boot order=scsi0 + +echo +echo "VM $VMID ($NAME) created. First boot configures SQL + Dispatch (a few minutes)." +if [ "$START" = 1 ]; then qm start "$VMID"; echo "Started."; else echo "Start it with: qm start $VMID"; fi +echo "Then browse to https://:8420 and set the admin password." diff --git a/appliance/kvm-README.txt b/appliance/kvm-README.txt new file mode 100644 index 00000000..bd8c6f5c --- /dev/null +++ b/appliance/kvm-README.txt @@ -0,0 +1,34 @@ +============================================================================== + Dispatch SMTP Relay - KVM / Proxmox appliance +============================================================================== + +This zip contains: + * dispatch-appliance.qcow2 the disk image (UEFI; Ubuntu 64-bit) + * dispatch-appliance.qcow2.sha256 checksum + * import-libvirt.sh one-command import for libvirt / virsh (KVM) + * import-proxmox.sh one-command import for Proxmox VE + * README.txt this file + +------------------------------------------------------------------------------ + Import - libvirt / KVM +------------------------------------------------------------------------------ + Run on the hypervisor host: + sudo ./import-libvirt.sh # creates + starts a UEFI VM from the qcow2 + +------------------------------------------------------------------------------ + Import - Proxmox VE +------------------------------------------------------------------------------ + Run on the Proxmox host: + sudo ./import-proxmox.sh # creates a VM and imports the qcow2 disk + + Open either script to see options (VM name, memory, network bridge, etc.). + +------------------------------------------------------------------------------ + After import +------------------------------------------------------------------------------ + * Start the VM. First boot configures SQL Server Express + Dispatch (allow a + few minutes). The VM uses DHCP. + * Browse to https://:8420 (self-signed cert - accept the warning) and + SET THE ADMIN PASSWORD on the first login. + * Full docs: https://chrismuench.github.io/Dispatch-SMTP-Relay/ +============================================================================== diff --git a/appliance/provision.sh b/appliance/provision.sh new file mode 100755 index 00000000..0a424676 --- /dev/null +++ b/appliance/provision.sh @@ -0,0 +1,94 @@ +#!/bin/sh +# +# Dispatch appliance - in-guest provisioning, run by virt-customize during the image build (NOT at runtime). +# virt-customize runs --run scripts with /bin/sh, so this is POSIX sh (no bashisms). Runs with NO network +# (libguestfs's passt networking is unreliable on CI runners): the SQL Server Express + tools packages are +# pre-downloaded on the host into /opt/stage/debs (see build-appliance.sh) and installed offline here. SQL is +# only unpacked - it's configured per-VM on first boot (appliance/firstboot.sh). +# +set -eu +export DEBIAN_FRONTEND=noninteractive +STAGE="/opt/stage" + +echo "==> Accept Microsoft EULAs (debconf) for SQL Server tools" +echo "msodbcsql18 msodbcsql/ACCEPT_EULA boolean true" | debconf-set-selections +echo "mssql-tools18 mssql-tools/accept_eula boolean true" | debconf-set-selections + +echo "==> Install SQL Server Express + tools (offline from pre-downloaded .debs)" +dpkg -i "$STAGE"/debs/*.deb || true # may report ordering warnings; configure resolves them +# Tolerant: a guest-agent postinst can be noisy in an offline chroot (no running systemd). SQL correctness is +# hard-verified immediately below, so a nonzero configure here must not abort the build. +dpkg --configure -a || true +test -x /opt/mssql/bin/mssql-conf || { echo "ERROR: mssql-server did not install" >&2; exit 1; } +test -x /opt/mssql-tools18/bin/sqlcmd || { echo "ERROR: mssql-tools18 did not install" >&2; exit 1; } + +echo "==> Enable hypervisor guest agents (best-effort; each idles harmlessly on a non-matching host)" +# So Hyper-V Manager / vCenter / Proxmox can show the VM's IP. The .debs are installed above; enable only the +# units that are present (systemctl --root creates the WantedBy symlinks without a running systemd). Not all +# agents install on every build (the Hyper-V set is kernel-tied and best-effort), so a missing unit is fine. +for unit in hv-kvp-daemon.service hv-vss-daemon.service hv-fcopy-daemon.service open-vm-tools.service qemu-guest-agent.service; do + if systemctl --root=/ enable "$unit" >/dev/null 2>&1; then echo " enabled $unit"; else echo " (skip $unit - not installed)"; fi +done + +echo "==> Stage Dispatch (enabled, not started; SA password finalized on first boot)" +bash "$STAGE/install.sh" --prebuilt "$STAGE/bin" --no-start \ + --sql-connection "Server=localhost;Database=DispatchLog;User Id=sa;Password=__SA_PASSWORD__;TrustServerCertificate=True;Encrypt=True" + +echo "==> Install the dispatch-set-ip helper" +install -m 755 "$STAGE/dispatch-set-ip" /usr/local/sbin/dispatch-set-ip + +echo "==> First-boot unit + start ordering" +install -m 755 -D "$STAGE/firstboot.sh" /opt/dispatch-appliance/firstboot.sh +install -m 644 "$STAGE/dispatch-firstboot.service" /etc/systemd/system/dispatch-firstboot.service +mkdir -p /etc/systemd/system/dispatch.service.d +# Dispatch must start only after first-boot configures SQL. +printf '[Unit]\nAfter=dispatch-firstboot.service\nRequires=dispatch-firstboot.service\n' \ + > /etc/systemd/system/dispatch.service.d/10-appliance.conf +# Enable both units offline. `systemctl --root=/` creates the WantedBy symlinks without a running systemd +# (a plain `systemctl enable` is a silent no-op in the build appliance), and the explicit ln is a fallback. +# Without this neither first-boot nor Dispatch starts at boot (VM comes up to a bare login - the boot smoke +# caught exactly that). +systemctl --root=/ enable dispatch-firstboot.service dispatch.service 2>&1 || echo "WARN: systemctl --root enable returned nonzero" +mkdir -p /etc/systemd/system/multi-user.target.wants +ln -sf ../dispatch-firstboot.service /etc/systemd/system/multi-user.target.wants/dispatch-firstboot.service +ln -sf ../dispatch.service /etc/systemd/system/multi-user.target.wants/dispatch.service + +echo "==> Ensure the UEFI removable/fallback bootloader path exists" +# A freshly-created VM (QEMU/OVMF, or a Hyper-V Gen2 VM importing this disk) starts with empty firmware +# NVRAM, so it boots via the removable-media path \EFI\BOOT\BOOTX64.EFI. Ubuntu only installs \EFI\ubuntu\, +# so copy shim (+ grub) into the fallback path. shim keeps Secure Boot working (MS UEFI CA template). +ESP=/boot/efi/EFI +if [ -d "$ESP/ubuntu" ]; then + mkdir -p "$ESP/BOOT" + [ -f "$ESP/ubuntu/shimx64.efi" ] && cp -f "$ESP/ubuntu/shimx64.efi" "$ESP/BOOT/BOOTX64.EFI" + [ -f "$ESP/ubuntu/grubx64.efi" ] && cp -f "$ESP/ubuntu/grubx64.efi" "$ESP/BOOT/grubx64.efi" + echo " fallback bootloader: $(ls "$ESP/BOOT" 2>/dev/null | tr '\n' ' ')" +else + echo " WARN: $ESP/ubuntu not found (ESP not mounted?) - skipping fallback bootloader" +fi + +echo "==> cloud-init: no cloud metadata service on Hyper-V - avoid boot-time probing delays" +mkdir -p /etc/cloud/cloud.cfg.d +printf 'datasource_list: [ NoCloud, None ]\n' > /etc/cloud/cloud.cfg.d/99-dispatch-datasource.cfg + +echo "==> Appliance console/SSH login: ubuntu / dispatch (must be changed on first login)" +# The cloud image's default user is locked (SSH-key only) and we inject no key, so without this nobody could +# log in to the console to set networking. cloud-init creates 'ubuntu' on first boot; give it a documented +# default password, force a change at first login, and enable SSH password auth (it's an on-LAN appliance). +# cloud-init creates the 'ubuntu' user (with sudo) on first boot; chpasswd then sets+unlocks its password +# and expires it (forced change at first login). ssh_pwauth enables SSH password auth. +cat > /etc/cloud/cloud.cfg.d/99-dispatch-login.cfg <<'CICFG' +ssh_pwauth: true +chpasswd: + expire: true + users: + - name: ubuntu + password: dispatch + type: text +CICFG + +echo "==> Cleanup" +rm -rf "$STAGE" +# Empty machine-id so each VM generates a unique one on first boot. +: > /etc/machine-id +echo "==> provisioning complete" diff --git a/appliance/qmp-screendump.py b/appliance/qmp-screendump.py new file mode 100644 index 00000000..4968d044 --- /dev/null +++ b/appliance/qmp-screendump.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Capture the QEMU VGA framebuffer via QMP. Usage: qmp-screendump.py """ +import socket +import sys +import time + +sock, out = sys.argv[1], sys.argv[2] +try: + s = socket.socket(socket.AF_UNIX) + s.connect(sock) + s.settimeout(5) + s.recv(65536) # QMP greeting + s.sendall(b'{"execute":"qmp_capabilities"}\n') + s.recv(65536) + s.sendall(b'{"execute":"screendump","arguments":{"filename":"%s"}}\n' % out.encode()) + s.recv(65536) + time.sleep(1) + print("screendump ->", out) +except Exception as e: # noqa: BLE001 - best-effort diagnostic + print("qmp screenshot failed:", e) diff --git a/appliance/vmware-README.txt b/appliance/vmware-README.txt new file mode 100644 index 00000000..ef8cb0b0 --- /dev/null +++ b/appliance/vmware-README.txt @@ -0,0 +1,31 @@ +============================================================================== + Dispatch SMTP Relay - VMware appliance +============================================================================== + +This zip contains: + * dispatch-appliance.ova the importable appliance (EFI, Ubuntu 64-bit) + * dispatch-appliance.ova.sha256 checksum + * README.txt this file + +------------------------------------------------------------------------------ + Import +------------------------------------------------------------------------------ + vSphere / ESXi: + vSphere Client -> Deploy OVF Template -> select dispatch-appliance.ova -> + accept the defaults (the descriptor already sets EFI firmware + Ubuntu 64-bit). + + Workstation / Fusion: + File -> Open -> dispatch-appliance.ova + + CLI (ovftool): + ovftool dispatch-appliance.ova vi://user@vcenter/Datacenter/host/esxi + +------------------------------------------------------------------------------ + After import +------------------------------------------------------------------------------ + * Power on the VM. First boot configures SQL Server Express + Dispatch (allow a + few minutes). The VM uses DHCP. + * Browse to https://:8420 (self-signed cert - accept the warning) and + SET THE ADMIN PASSWORD on the first login. + * Full docs: https://chrismuench.github.io/Dispatch-SMTP-Relay/ +============================================================================== diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..c71ba30b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,53 @@ +# Dispatch SMTP Relay - full local stack (Dispatch + SQL), arm64-native on Apple Silicon. +# +# docker compose up --build # build + run everything +# open https://localhost:8420 # dashboard (self-signed cert; log in with ADMIN password below) +# +# Azure SQL Edge is arm64-native so it runs without emulation on Apple Silicon. It speaks the SQL +# Server wire protocol, so Dispatch talks to it unchanged. Edge is fine for local testing; production +# should use SQL Server 2025 (the installers) or Azure SQL. +services: + sql: + image: mcr.microsoft.com/azure-sql-edge:latest + container_name: dispatch-sql + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "Dispatch_Dev_Pass123" + ports: + - "1433:1433" + volumes: + - dispatch-sql-data:/var/opt/mssql + healthcheck: + # Edge has no sqlcmd; a TCP open on 1433 (bash /dev/tcp) is enough to gate Dispatch's start, + # and Dispatch retries via its restart policy if the server isn't accepting logins yet. + test: ["CMD-SHELL", "timeout 1 bash -c '-x64.exe` + `Dispatch--x64.msi`) and, if signing is configured, + Authenticode-signs both (engine detached, signed, reattached, then the bundle is signed). +3. Builds the **universal Linux tarball** (`dispatch--linux.tar.gz`, x64 + arm64, self-contained) - + `install.sh` auto-detects the arch; no .NET SDK needed on the box. +4. Builds the **upgrade package** (`dispatch-upgrade-.tar.gz`) - one cross-platform file with every + platform's payload (linux-x64, linux-arm64, win-x64) and a signed manifest - for the dashboard's + "upload an upgrade package" self-update flow (see [Upgrading](https://chrismuench.github.io/Dispatch-SMTP-Relay/deployment/upgrading/)). + It is signed if `DISPATCH_UPDATE_SIGNING_KEY` is set (see below). +5. Generates `SHA256SUMS` and publishes a **GitHub Release** with all assets and auto-generated notes. + +### Versioning + +- Use `vMAJOR.MINOR.PATCH` (e.g. `v1.2.3`). +- A version containing a hyphen (e.g. `v1.2.3-rc1`) is published as a **pre-release**. +- The MSI uses `MajorUpgrade`, so bumping the version lets an installed instance upgrade in place. + +### Dry run (no tag) + +Run the **Release** workflow manually (`workflow_dispatch`) with a `version` input. It builds everything +and publishes a **draft** release you can inspect and delete - nothing goes public. + +## What users download + +| Platform | Asset | Notes | +|----------|-------|-------| +| Windows | **`DispatchSetup--x64.exe`** | The single file. Installs SQL Server 2025 Express → the MSI → the service. | +| Windows | `Dispatch--x64.msi` | Advanced: install against an existing SQL Server (`msiexec /i Dispatch--x64.msi SQLCONN="..."`). | +| Linux | `dispatch--linux.tar.gz` | Universal (x64 + arm64). Extract, then `sudo ./install.sh --install-sql ...` (auto-detects arch). | +| Upgrade | `dispatch-upgrade-.tar.gz` | One cross-platform package for the **dashboard self-update** (Updates page). Upload it on any appliance/Linux/Windows install. | +| Any | `ghcr.io/chrismuench/dispatch-smtp-relay:` | Multi-arch (amd64+arm64) container image; pushed to GHCR by the `docker` job. | +| All | `SHA256SUMS` | Verify downloads: `sha256sum -c SHA256SUMS`. | + +## First release - one-time setup + +The first tag push creates the GHCR package, but **GHCR packages are private by default**, so anonymous +`docker pull` will fail until you make it public: + +1. Push the first tag (e.g. `v1.0.0`) and let the **Release** workflow's `docker` job publish the image. +2. Go to the repo's **Packages** → `dispatch-smtp-relay` → **Package settings** → **Change visibility → Public** + (and, optionally, **Connect repository** so the package shows on the repo page). +3. Verify anonymous pull works on a clean machine: + ```bash + docker pull ghcr.io/chrismuench/dispatch-smtp-relay:1.0.0 + ``` + +Subsequent releases reuse the same (now public) package - this step is only needed once. + +## Code signing (Azure Artifact Signing) + +Signing is **opt-in and dormant** until provisioned - until then releases publish unsigned (Windows +SmartScreen will warn on first run). To enable it: + +1. Set up an **Azure Artifact Signing** account + certificate profile (renamed from Trusted Signing, + Jan 2026; ~$9.99/mo). Create a certificate profile and note the account name, profile name, and the + regional endpoint (e.g. `https://eus.codesigning.azure.net/`). +2. Create a federated (OIDC) Azure service principal and grant it the **Artifact Signing Certificate + Profile Signer** role on the account. +3. In the GitHub repo settings add: + - **Variables:** `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE` + - **Secrets:** `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` + +The next tagged release then signs the MSI and the bundle automatically. The signing steps key off +`AZURE_SIGNING_ACCOUNT`; if it's unset, they're skipped. + +> Note: code signing currently uses Azure Artifact Signing (a paid service tied to your own identity). +> As an Apache-2.0 open-source project, Dispatch may also qualify for a free OSS code-signing program +> such as the **SignPath Foundation**. + +## Upgrade-package signing key (one-time) + +The dashboard self-update will only apply a package whose manifest is signed by the release key. The +**public** key is committed (`src/Dispatch.Core/Updates/dispatch-update-public.pem`, embedded in the app +and shipped to installs); the **private** key lives only in the `DISPATCH_UPDATE_SIGNING_KEY` GitHub +Actions secret. Until the secret is set, releases emit an **unsigned** package that the updater refuses. + +Generate the key (RSA-3072) and store the public half in the repo, the private half in the secret: + +```bash +# from the repo root +openssl genrsa -out dispatch-update-signing.key 3072 +openssl rsa -in dispatch-update-signing.key -pubout -out src/Dispatch.Core/Updates/dispatch-update-public.pem + +# store the PRIVATE key as a repo secret (never commit it) +gh secret set DISPATCH_UPDATE_SIGNING_KEY < dispatch-update-signing.key + +# commit the regenerated PUBLIC key +git add src/Dispatch.Core/Updates/dispatch-update-public.pem +git commit -m "chore: set update signing key" +``` + +Then move `dispatch-update-signing.key` somewhere safe (a password manager / secrets vault) and delete the +local copy. **Keep it** - you need the same key to sign every future release, or already-deployed installs +(which trust the committed public key) will reject new packages. If you ever rotate it, re-run the steps +above and re-sign the interop test vector: `printf '{"version":"0.1.0"}' | openssl dgst -sha256 -sign dispatch-update-signing.key -out tests/Dispatch.Core.Tests/testdata/sample.manifest.json.sig`. + +In the GitHub UI the secret is at **Settings → Secrets and variables → Actions → New repository secret**, +name `DISPATCH_UPDATE_SIGNING_KEY`, value = the full PEM (`-----BEGIN PRIVATE KEY-----` ... `-----END...`). + +## CI vs. release builds + +[`installers.yml`](../.github/workflows/installers.yml) validates that the installer **sources compile** +on every push that touches `installer/**` (and runs an opt-in end-to-end SQL-install smoke test). Those CI +builds are intentionally **unsigned**. Signing happens only at release time, in `release.yml`. diff --git a/docs/SPEC.md b/docs/SPEC.md index d5114fe7..d41c13ac 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1,5 +1,5 @@ # Dispatch SMTP Relay -### Open-Source .NET SMTP Relay — Forward mail from your apps to any cloud provider — Technical Specification v1.0 +### Open-source .NET Email Relay - Forward mail from your apps to any cloud provider - Technical Specification v1.0 *June 2026* --- @@ -25,39 +25,59 @@ 17. [Security Considerations](#17-security-considerations) 18. [CI/CD and Build Pipeline](#18-cicd-and-build-pipeline) 19. [Claude Code Implementation Notes](#19-claude-code-implementation-notes) -20. [Open Source & Licence](#20-open-source--licence) -- [Appendix A — Suggested Future Providers](#appendix-a--suggested-future-providers) -- [Appendix B — Suggested Future Features](#appendix-b--suggested-future-features) +20. [Licence](#20-licence) +- [Appendix A - Suggested Future Providers](#appendix-a--suggested-future-providers) +- [Appendix B - Suggested Future Features](#appendix-b--suggested-future-features) --- ## 1. Project Overview -Dispatch is a lightweight, open-source SMTP relay server built on .NET 9. It accepts inbound messages over **SMTP** (ports 25/587) and a **developer-friendly HTTP API** (port 8081), then forwards every message to a configurable upstream provider such as Mailgun, SendGrid, or Azure Communication Services. +Dispatch is a lightweight, open-source SMTP relay server built on .NET 10. It accepts inbound messages over **SMTP** (ports 25/587) and a **developer-friendly HTTP API** (port 8025), then forwards every message to a configurable upstream provider such as Mailgun, SendGrid, or Azure Communication Services. -A small embedded web UI — served directly from the process on port 8080 — lets administrators view live message logs, manage API keys, and configure all settings from any browser without installing separate software. No Electron, no Avalonia, no external web server required. +A small embedded web UI - served directly from the process on port 8420 - lets administrators view live message logs, manage API keys, and configure all settings from any browser without installing separate software. No Electron, no Avalonia, no external web server required. **Key design goals:** -- Zero external runtime dependencies — single self-contained executable on both Windows and Linux -- **Dual ingest** — accept mail via SMTP or HTTP API; same spool pipeline, same delivery guarantees +- Zero external runtime dependencies - single self-contained executable on both Windows and Linux +- **Dual ingest** - accept mail via SMTP or HTTP API; same spool pipeline, same delivery guarantees - Embedded ASP.NET Core web UI for configuration, log inspection, and API key management - Windows: installs as a Windows Service via MSI - Linux: runs as a systemd unit -- Provider-agnostic relay — swap between Mailgun, SendGrid, SMTP, Azure Communication Services, or others through the UI without restarting -- AGPL-3.0 + Commons Clause — free to use and self-host; selling the software or charging for hosted access is prohibited +- Provider-agnostic relay - swap between Mailgun, SendGrid, SMTP, Azure Communication Services, or others through the UI without restarting +- Free and open source - released under the Apache License 2.0; no license key required to run + +### 1.1 Implementation deltas (authoritative) + +The shipped implementation intentionally diverges from a few details described later in this document. Where they conflict, the notes here win; the older passages are kept for context but should be read with these in mind: + +- **Default SMTP ports are `25` and `587`, with an automatic fallback to `2525`.** The installers, appliance, and container all run with enough privilege to bind the standard ports (the systemd unit is granted `CAP_NET_BIND_SERVICE`). At startup the listener probes each configured port and, if `25` can't be bound (already in use, or no privilege - e.g. a non-elevated dev run), falls back to `2525` so mail still flows. A bind failure never crashes the host: if no port can be bound the SMTP listener is skipped and the dashboard/API stay up. Recommendation: run on a host with no other SMTP software so 25/587 are free. (Affects §5.3, §12.3 `listener.ports`.) +- **No "None" provider.** The provider model is `Unconfigured` (the out-of-the-box default - never delivers, fails clearly) plus `Local` (developer mode - captures to `spool/captured/`, never delivers externally). The schema/enum has no `None` value; references to provider `None` mean `Local`/`Unconfigured`. (Affects §10.2, §10.4/§10.5, §11.6.) +- **No CSV export.** The async CSV export (job endpoints, Export UI) was dropped as out of scope. (Affects §9.2 Export, §9.3 `/api/messages/export*`.) +- **Logging is repository-driven, not Serilog custom sinks.** Relay events reach `relay_log` via `ILogRepository.InsertAsync` and the live feed via a `BroadcastingLogRepository` decorator + `RelayEventStream`; there is no `RelayLogDbSink`/`RingBufferSink`. File + console Serilog sinks remain. (Affects §13.1/§13.2.) +- **`api_keys.rate_limit_per_minute` default is `100`** (the spec is internally inconsistent - §6.11 says 0, §7.6 says 100; code uses 100; 0 still means "use the global default"). +- **Web UI HTTPS is enforced (§17.2).** The dashboard uses the configured `WebUi:TlsCertPath` (appsettings, §12.2) when present, otherwise the **service** auto-generates and persists a self-signed cert (`dispatch-webui.pfx` in the content root) - it never serves plain HTTP. The cert is generated by the app at startup, not by the installer/bootstrap. The ingestion API remains plain HTTP. +- **Config provider settings** are edited per-relay via `PUT /api/relays/{id}` (named relays, §10), not a global `PUT /api/config/provider`; live in-flight counts are part of `GET /api/stats/relays`. (Affects §9.3.) +- **Default source-IP allow-lists are deployment-friendly, not loopback-only.** A loopback-only default makes the dashboard unreachable on the common deployment shapes (headless servers have no local browser; containers NAT every request). So the seeded defaults are: `webui.allowed_cidrs` and `api.allowed_cidrs` = **empty (allow all)** - these surfaces are gated by the dashboard password and API keys respectively, with the CIDR list as optional hardening; `listener.allowed_cidrs` = **loopback + private ranges** (`127.0.0.1/32`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7`) so same-host apps, private LANs and Docker networks can submit mail while the SMTP listener is **not** an open internet relay out of the box. Operators tighten any of these in the dashboard. (Affects §12.6 defaults, §17.10.) +- **Provider set is larger than §8 describes.** Implemented providers: `Unconfigured` (default), `Local`, `Smtp`, `Mailgun`, `SendGrid`, `AzureCommunication`, **`AmazonSes`, `Postmark`, `Resend`, `SparkPost`, `Smtp2Go`**. The five bold ones were on Appendix A ("future") but are shipped; their credential field schemas live in `RelayProviderSchema` (Core). Anywhere §8/§10.2 says the default relay provider is `None`, read `Unconfigured`. (Affects §8, §10.2, Appendix A.) +- **Windows install is a WiX Burn bundle, not a WinForms wizard.** §15.2 describes a standalone .NET WinForms setup wizard; the actual installer is `DispatchSetup.exe`, a WiX Burn bundle (`bundle/Bundle.wxs`) that chains a thin `InstallSqlExpress.exe` launcher (runs `InstallSqlExpress.ps1` to install **SQL Server 2025 Express**, instance `DISPATCHSQL`) then the Dispatch MSI. There is no interactive multi-step UI and no certificate-generation step on Windows - automatic self-signed cert generation exists only on Linux via `install.sh --generate-cert`. (Affects §15.2, §17.2.) +- **The ingestion API is HTTP-only; no API-TLS config keys.** There are no `api.tls_cert_path` / `api.tls_cert_password` config keys and no API-TLS UI controls - Kestrel serves the ingestion port over plain HTTP (terminate TLS at a reverse proxy if needed). The `webui.tls_cert_path` / `webui.tls_cert_password` settings live in **appsettings.json** (§12.2), not the SQL `config` table, so they are not rows in the §12.3 table. There is also no `webui.require_auth` key - the dashboard requires auth whenever an admin password hash is set. Two config keys exist that §12.3 omits: `listener.server_name` (default `Dispatch`) and `purge.captured_retention_days` (default `7`). (Affects §9.2, §12.3.) +- **Some §-pseudocode signatures are illustrative.** The internal method shapes shown in §10.7 (`RoutingEngine.ResolveAsync`) and §11.5 (provider-test service) differ from the code - e.g. `ResolveAsync` returns `ValueTask` over `IReadOnlyList` recipients, and starting a provider test is synchronous and returns a `TestRun`. Treat those listings as intent, not literal API. +- **SMTP source-IP denial happens at MAIL FROM, not the greeting.** The CIDR allow-list and intake/back-pressure checks run in the mailbox filter (`CanAcceptFromAsync`); a disallowed source is refused there (and a `Denied` row is logged), rather than with a `554` at the SMTP banner. (Refines §5.3, §17.10.) +- **SMTP AUTH brute-force lockout** (§17.10) is implemented as `SmtpAuthThrottle`: 5 failed AUTH attempts from a source IP lock it out for 60 seconds, during which AUTH is refused without touching the credential store (mirrors the dashboard's `LoginThrottle`). +- **API-key revocation is immediate** (§17.4): revoking a key calls `ApiKeyCache.Invalidate`, so it stops working at once rather than lingering until the 30-second cache TTL. --- ## 2. Architecture Overview -Dispatch runs as a single .NET 9 process. The design principle is simple: **the hot path never touches a database**. `250 OK` is returned as soon as a message hits the spool directory. Everything else — provider dispatch, retry, logging — happens asynchronously afterwards. +Dispatch runs as a single .NET 10 process. The design principle is simple: **the hot path never touches a database**. `250 OK` is returned as soon as a message hits the spool directory. Everything else - provider dispatch, retry, logging - happens asynchronously afterwards. **SMTP Listener Layer** -Accepts inbound connections on port 25 and/or 587. Built on the `SmtpServer` NuGet library (v11.x). When a `DATA` command completes successfully, the raw message bytes are written atomically to a `.eml` file in the spool `incoming/` directory and `250 OK` is returned immediately. No database, no parsing, no network call — just a file write. +Accepts inbound connections on port 25 and/or 587. Built on the `SmtpServer` NuGet library (v11.x). When a `DATA` command completes successfully, the raw message bytes are written atomically to a `.eml` file in the spool `incoming/` directory and `250 OK` is returned immediately. No database, no parsing, no network call - just a file write. **HTTP Ingestion API Layer** -A second ASP.NET Core listener on port 8081 (default, separate from the web UI port). Accepts `POST /api/v1/messages` with `multipart/form-data` or JSON. API keys are required (managed in the web UI). On receipt, MimeKit builds a `MimeMessage` from the POST fields, serialises it to RFC 5322 bytes, and writes it to `spool/incoming/` — identical to the SMTP path. Returns `202 Accepted` immediately. +A second ASP.NET Core listener on port 8025 (default, separate from the web UI port). Accepts `POST /api/v1/messages` with `multipart/form-data` or JSON. API keys are required (managed in the web UI). On receipt, MimeKit builds a `MimeMessage` from the POST fields, serialises it to RFC 5322 bytes, and writes it to `spool/incoming/` - identical to the SMTP path. Returns `202 Accepted` immediately. **Spool Directory (Durable Queue)** The spool directory on the local filesystem is the source of truth for all in-flight messages. It has three subdirectories: @@ -69,7 +89,7 @@ spool/ failed/ ← file moved here after all retries exhausted ``` -File moves between directories are atomic on the same filesystem — there are no partial states. A `FileSystemWatcher` wakes workers the instant a file appears in `incoming/` so there is no polling delay. On startup, the worker sweeps `processing/` for any files orphaned by a previous crash and requeues them. +File moves between directories are atomic on the same filesystem - there are no partial states. A `FileSystemWatcher` wakes workers the instant a file appears in `incoming/` so there is no polling delay. On startup, the worker sweeps `processing/` for any files orphaned by a previous crash and requeues them. **Relay Worker Pool** A configurable pool of background workers (default: 4) each claim one spool file at a time by moving it from `incoming/` to `processing/`. The worker parses the `.eml` with MimeKit, runs the **routing engine** to select the correct named relay for this message's sender/recipient domains, calls the relay's `IRelayProvider`, then: @@ -78,31 +98,31 @@ A configurable pool of background workers (default: 4) each claim one spool file - **Transient failure** → increment `relay_counters.retried`; write `Retrying` row to `relay_log` only if `logging.log_retrying = true`; update `.meta` sidecar; sleep back-off; retry - **Permanent failure** → increment `relay_counters.failed`; write `Failed` row to `relay_log` (always); move file to `failed/` -SQL Server is only written to **after** the provider responds. If SQL Server is unavailable, mail still flows — the log entry will be missing from the UI until SQL recovers, but no messages are lost. +SQL Server is only written to **after** the provider responds. If SQL Server is unavailable, mail still flows - the log entry will be missing from the UI until SQL recovers, but no messages are lost. -**SQL Server — Logging & Config Only** +**SQL Server - Logging & Config Only** SQL Server Express holds `relay_log` (event history, configurable suppression), `relay_counters` (daily aggregates, always written), the `config` table, and the schema version table. **Embedded Web UI Layer** -ASP.NET Core minimal API hosts a React/Vite SPA on port 8080 (default). The UI reads `relay_log` for the Message Log, reads `spool/` directory counts for the queue depth widget, and streams live events via SignalR. +ASP.NET Core minimal API hosts a React/Vite SPA on port 8420 (default). The UI reads `relay_log` for the Message Log, reads `spool/` directory counts for the queue depth widget, and streams live events via SignalR. | Component | Technology | |---|---| | SMTP listener | SmtpServer 11.x (NuGet) | -| **HTTP ingestion API** | **ASP.NET Core minimal API — second listener on port 8081** | +| **HTTP ingestion API** | **ASP.NET Core minimal API - second listener on port 8025** | | Message parsing | MimeKit 4.x (NuGet) | | Relay dispatch | MailKit SmtpClient + provider SDKs | -| **Durable queue** | **Local spool directory — `.eml` files, atomic directory moves** | +| **Durable queue** | **Local spool directory - `.eml` files, atomic directory moves** | | Worker wake signal | `FileSystemWatcher` + `Channel` (filename only) | -| Relay event log | SQL Server Express — `relay_log` (configurable) + `relay_counters` (always) | +| Relay event log | SQL Server Express - `relay_log` (configurable) + `relay_counters` (always) | | Log ORM | Microsoft.Data.SqlClient + Dapper | -| Web host | ASP.NET Core 9 minimal API | +| Web host | ASP.NET Core 10 minimal API | | Real-time log push | SignalR (Microsoft.AspNetCore.SignalR) | | Web UI framework | React 18 + Vite (compiled, embedded in assembly) | | Configuration store | SQL Server `config` table (connection string only in `appsettings.json`) | -| Structured logging | Serilog — file sink + SQL Server sink (`relay_log`) + in-memory ring buffer | +| Structured logging | Serilog - file sink + SQL Server sink (`relay_log`) + in-memory ring buffer | | Windows service host | .NET Worker Service (IHostedService) | -| Windows installer | WiX Toolset v5 (MSI) | +| Windows installer | WiX Toolset v6 (MSI) | | Linux service | systemd unit file | --- @@ -118,13 +138,14 @@ Dispatch/ Dispatch.Providers/ # Upstream relay provider implementations Dispatch.Service/ # Entry point, IHostedService wiring, DI setup installer/ - windows/ # WiX v5 .wxs files for MSI + windows/ # WiX v6 .wxs files for MSI linux/ # dispatch.service systemd unit template + install.sh docs/ # Architecture diagrams, user guide tests/ Dispatch.Core.Tests/ + Dispatch.Providers.Tests/ Dispatch.Web.Tests/ - Dispatch.Integration.Tests/ + Dispatch.Data.Tests/ ``` **Spool directory locations (runtime, not in repo):** @@ -140,7 +161,7 @@ Dispatch/ | Package | Purpose | Licence | |---|---|---| -| SmtpServer (v11.x) | Embeddable SMTP server — handles ESMTP protocol, TLS, AUTH | MIT | +| SmtpServer (v11.x) | Embeddable SMTP server - handles ESMTP protocol, TLS, AUTH | MIT | | MimeKit (v4.x) | RFC-compliant MIME message parsing and construction | MIT | | MailKit (v4.x) | SMTP client used for relay-over-SMTP upstream delivery | MIT | | Serilog | Structured logging with multiple sinks | Apache 2.0 | @@ -166,12 +187,12 @@ Dispatch/ ### 5.2 Supported ESMTP Extensions -- `STARTTLS` — optional TLS upgrade (certificate path configured in web UI) -- `AUTH PLAIN` / `AUTH LOGIN` — optional credential check against a configured allow-list -- `SIZE` — advertises `listener.max_message_bytes` (the global ceiling; default 0 = no limit, omitted from `EHLO`); per-relay limits enforced at `RCPT TO` — see Section 14.2 -- `8BITMIME`, `PIPELINING` — standard modern ESMTP extensions +- `STARTTLS` - optional TLS upgrade (certificate path configured in web UI) +- `AUTH PLAIN` / `AUTH LOGIN` - optional credential check against a configured allow-list +- `SIZE` - advertises `listener.max_message_bytes` (the global ceiling; default 0 = no limit, omitted from `EHLO`); per-relay limits enforced at `RCPT TO` - see Section 14.2 +- `8BITMIME`, `PIPELINING` - standard modern ESMTP extensions -**Custom header — `X-Dispatch-Tag`:** +**Custom header - `X-Dispatch-Tag`:** SMTP senders can tag messages by including one or more `X-Dispatch-Tag` headers. Tags appear in the Message Log and can be filtered on: @@ -180,7 +201,7 @@ X-Dispatch-Tag: welcome X-Dispatch-Tag: onboarding ``` -Tags are parsed from the raw message at receipt time (minimal MIME header scan only — not a full parse) and stored in `SpoolMeta.Tags`, then written to `relay_log.tags` as a JSON array. +Tags are parsed from the raw message at receipt time (minimal MIME header scan only - not a full parse) and stored in `SpoolMeta.Tags`, then written to `relay_log.tags` as a JSON array. ### 5.3 Listener Configuration Defaults @@ -191,13 +212,13 @@ Tags are parsed from the raw message at receipt time (minimal MIME header scan o | Allowed source IPs / CIDRs | `127.0.0.1/32` (localhost only) | | Max message size (global ceiling) | No limit (0) | | Require AUTH | false | -| TLS certificate path | (empty — plain text by default) | +| TLS certificate path | (empty - plain text by default) | | Connection timeout | 60 s | | Max concurrent connections | 100 | The SMTP listener binds to all interfaces so connections arrive regardless of which network interface they come from. Access control is enforced at the **application layer** via an IP/CIDR allow-list (`listener.allowed_cidrs`). Connections from addresses outside the allow-list are refused at the SMTP greeting with `554 5.7.1 Connection refused` and a `Denied` entry is written to `relay_log` with the source IP, so every rejection is visible in the web UI. -Default allow-list is `127.0.0.1/32` (localhost only). To accept from your local network, add your subnet — e.g. `192.168.1.0/24`. To accept from anywhere, set `0.0.0.0/0` (open relay — only do this with `Require AUTH` enabled). +Default allow-list is `127.0.0.1/32` (localhost only). To accept from your local network, add your subnet - e.g. `192.168.1.0/24`. To accept from anywhere, set `0.0.0.0/0` (open relay - only do this with `Require AUTH` enabled). When **Require AUTH** is enabled, Dispatch validates credentials against a username/password list stored in the `config_smtp_credentials` table in SQL Server. The web UI provides a credential manager for this list. @@ -234,13 +255,13 @@ Parse with MimeKit, run RoutingEngine → call IRelayProvider failure Move file to spool/failed/ ``` -SQL Server is written to **only after** the provider responds. If SQL Server is down, mail still flows and spool files accumulate normally — the `relay_log` insert is fire-and-forget. A failed insert is logged to the Serilog file sink (so the event is never silently lost) and the spool file is still deleted on delivery success. The UI Message Log will have gaps for the outage period; no messages are lost. +SQL Server is written to **only after** the provider responds. If SQL Server is down, mail still flows and spool files accumulate normally - the `relay_log` insert is fire-and-forget. A failed insert is logged to the Serilog file sink (so the event is never silently lost) and the spool file is still deleted on delivery success. The UI Message Log will have gaps for the outage period; no messages are lost. ### 6.2 Spool Directory Structure | Path | Contents | Lifecycle | |---|---|---| -| `spool/incoming/` | `{uuid}.eml` — raw RFC 5322 bytes | Written on receipt; claimed by a worker within milliseconds | +| `spool/incoming/` | `{uuid}.eml` - raw RFC 5322 bytes | Written on receipt; claimed by a worker within milliseconds | | `spool/processing/` | `{uuid}.eml` + `{uuid}.meta` | File moved atomically from `incoming/`; deleted on success or moved to `failed/` | | `spool/failed/` | `{uuid}.eml` + `{uuid}.meta` | Permanently failed messages; visible in UI; can be manually retried or deleted | @@ -255,9 +276,9 @@ The path is configurable in the web UI under **Settings → Storage & Retention* ### 6.3 Spool File Format -**`{uuid}.eml`** — the complete raw RFC 5322 message bytes exactly as received from the SMTP client. No transformation, no parsing on the write path. +**`{uuid}.eml`** - the complete raw RFC 5322 message bytes exactly as received from the SMTP client. No transformation, no parsing on the write path. -**`{uuid}.meta`** — a small JSON sidecar file written when a worker first claims the message, updated on each retry: +**`{uuid}.meta`** - a small JSON sidecar file written when a worker first claims the message, updated on each retry: ```json { @@ -277,7 +298,7 @@ The path is configurable in the web UI under **Settings → Storage & Retention* The `.meta` file is the only thing that changes during retries. The `.eml` file is immutable once written. `ingestSource`, `sourceIp`, and `tags` are set at receipt time and never change. `lastRelayId` is set by the worker after the first dispatch attempt so the counter increment on retry/failure knows which relay to attribute the event to. -### 6.4 Receive Path — Writing to Spool +### 6.4 Receive Path - Writing to Spool `MessageStore` implements SmtpServer's `IMessageStore`. It is the only thing that runs before `250 OK`: @@ -293,10 +314,10 @@ public class SpoolMessageStore(SpoolDirectory spool) : IMessageStore var id = Guid.NewGuid(); var path = spool.IncomingPath(id); - // Write raw bytes — no parsing, no DB, no network + // Write raw bytes - no parsing, no DB, no network await File.WriteAllBytesAsync(path, buffer.ToArray(), ct); - // Ring the doorbell — wakes an idle worker immediately + // Ring the doorbell - wakes an idle worker immediately spool.Signal(id); return SmtpResponse.Ok; @@ -306,18 +327,18 @@ public class SpoolMessageStore(SpoolDirectory spool) : IMessageStore The only failure mode that prevents `250 OK` is a full disk. Everything else (SQL down, provider unreachable, network issues) happens after the sender has already been acknowledged. -### 6.5 Worker Pool — Claiming and Dispatching +### 6.5 Worker Pool - Claiming and Dispatching -`SpoolWorkerPool` is an `IHostedService` that maintains a configurable number of concurrent worker tasks (default: 4, max: 32 — set via `spool.worker_count`). +`SpoolWorkerPool` is an `IHostedService` that maintains a configurable number of concurrent worker tasks (default: 4, max: 32 - set via `spool.worker_count`). #### Per-Relay Concurrency Control -Each named relay has a `max_concurrency` setting (default: 4). This is enforced by a `SemaphoreSlim` per relay, held in a `ConcurrentDictionary` keyed by `relay_id`. When a worker claims a spool file it must acquire the semaphore for the file's target relay before dispatching. If the semaphore is at capacity — meaning that relay already has `max_concurrency` dispatches in flight — the worker releases the file back and tries the next one. +Each named relay has a `max_concurrency` setting (default: 4). This is enforced by a `SemaphoreSlim` per relay, held in a `ConcurrentDictionary` keyed by `relay_id`. When a worker claims a spool file it must acquire the semaphore for the file's target relay before dispatching. If the semaphore is at capacity - meaning that relay already has `max_concurrency` dispatches in flight - the worker releases the file back and tries the next one. This gives two important properties: -- **No relay starvation** — a slow relay (e.g. Mailgun-EU at 800ms/message with `max_concurrency=4`) cannot monopolise all 4 global workers. If all 4 Mailgun-EU slots are taken, a worker skips those files and picks up the next file that resolves to a relay with a free slot (e.g. SendGrid). -- **Per-relay back-pressure** — if a relay starts rate-limiting (429 responses), reducing its `max_concurrency` in the UI immediately throttles the concurrency against it without touching other relays. +- **No relay starvation** - a slow relay (e.g. Mailgun-EU at 800ms/message with `max_concurrency=4`) cannot monopolise all 4 global workers. If all 4 Mailgun-EU slots are taken, a worker skips those files and picks up the next file that resolves to a relay with a free slot (e.g. SendGrid). +- **Per-relay back-pressure** - if a relay starts rate-limiting (429 responses), reducing its `max_concurrency` in the UI immediately throttles the concurrency against it without touching other relays. #### Worker Loop @@ -354,7 +375,7 @@ private async Task<(string emlPath, SemaphoreSlim semaphore)?> ClaimFileForAvail { var candidates = Directory .EnumerateFiles(_spool.IncomingDir, "*.eml") - .ToList(); // snapshot — avoid enumerator invalidation + .ToList(); // snapshot - avoid enumerator invalidation foreach (var candidate in candidates) { @@ -362,16 +383,16 @@ private async Task<(string emlPath, SemaphoreSlim semaphore)?> ClaimFileForAvail // The meta is written at enqueue time with from/to so routing can run here SpoolMeta meta; try { meta = SpoolMeta.Peek(candidate); } - catch { continue; } // file may have been claimed by another worker — skip + catch { continue; } // file may have been claimed by another worker - skip var relay = await _routing.ResolveAsync(meta.FromAddress, meta.ToAddresses); var sem = _semaphores.GetOrAdd(relay.Id, _ => new SemaphoreSlim(relay.MaxConcurrency, relay.MaxConcurrency)); - // Try to acquire without waiting — if slot available, claim the file - if (!sem.Wait(0)) continue; // this relay is at capacity — try next file + // Try to acquire without waiting - if slot available, claim the file + if (!sem.Wait(0)) continue; // this relay is at capacity - try next file - // We hold the semaphore — now atomically claim the file + // We hold the semaphore - now atomically claim the file var dest = _spool.ProcessingPath(Path.GetFileName(candidate)); try { @@ -386,7 +407,7 @@ private async Task<(string emlPath, SemaphoreSlim semaphore)?> ClaimFileForAvail } } - return null; // all files are for at-capacity relays — wait for next signal + return null; // all files are for at-capacity relays - wait for next signal } ``` @@ -394,7 +415,7 @@ private async Task<(string emlPath, SemaphoreSlim semaphore)?> ClaimFileForAvail #### Semaphore Lifecycle -Semaphores are created lazily on first access and stored in `ConcurrentDictionary`. When a relay's `max_concurrency` is changed in the web UI, the old semaphore is replaced with a new one sized to the new value. The replacement is safe — any workers already holding the old semaphore complete normally; new claims use the new semaphore. +Semaphores are created lazily on first access and stored in `ConcurrentDictionary`. When a relay's `max_concurrency` is changed in the web UI, the old semaphore is replaced with a new one sized to the new value. The replacement is safe - any workers already holding the old semaphore complete normally; new claims use the new semaphore. ```csharp public void UpdateRelayConcurrency(int relayId, int newMax) @@ -412,7 +433,7 @@ The two settings work together: |---|---|---| | 8 workers | Mailgun: 4, SendGrid: 4 | Up to 8 concurrent dispatches, evenly split | | 8 workers | Mailgun: 2, SendGrid: 6 | Mailgun capped at 2; SendGrid can use up to 6 | -| 4 workers | Mailgun: 10, SendGrid: 10 | Global cap of 4 wins — can never exceed worker count | +| 4 workers | Mailgun: 10, SendGrid: 10 | Global cap of 4 wins - can never exceed worker count | | 4 workers | Mailgun: 1, SendGrid: 1 | Both relays capped at 1 even if workers are free | The effective concurrency for any relay is `min(spool.worker_count, relay.max_concurrency)`. Setting `max_concurrency = 0` means unlimited (bounded only by `spool.worker_count`). @@ -434,7 +455,7 @@ private async Task ProcessAsync(string emlPath, CancellationToken ct) // Always: increment relay_counters (counters are always accurate) await _counters.IncrementAsync(relay.Id, CounterField.Delivered); - _minuteRing.Increment(); // in-memory sparkline — always updated + _minuteRing.Increment(); // in-memory sparkline - always updated // Conditional: write relay_log row only if success logging is enabled var logDelivered = _config.Get("logging.log_delivered", defaultValue: true); @@ -527,11 +548,11 @@ private async Task ProcessAsync(string emlPath, CancellationToken ct) | 3rd retry (final) | 30 minutes | | After 3rd failure | Moved to `spool/failed/`; `Failed` row written to `relay_log` | -Retry counts and delays are configurable in the web UI. The retry state lives in the `.meta` sidecar file — no database involved. +Retry counts and delays are configurable in the web UI. The retry state lives in the `.meta` sidecar file - no database involved. ### 6.8 Startup Recovery -On every service start, `SpoolWorkerPool` scans `spool/processing/` for orphaned files — messages that were claimed by a worker that crashed before completing. Each orphaned file is moved back to `spool/incoming/` so it will be retried: +On every service start, `SpoolWorkerPool` scans `spool/processing/` for orphaned files - messages that were claimed by a worker that crashed before completing. Each orphaned file is moved back to `spool/incoming/` so it will be retried: ```csharp private void RecoverOrphanedFiles() @@ -550,9 +571,9 @@ private void RecoverOrphanedFiles() Files in `spool/failed/` are surfaced in the web UI Message Log with status `Failed`. From the UI, administrators can: -- **View** — see full headers and the provider error from the `.meta` sidecar -- **Retry** — move the file back to `spool/incoming/` and reset `retryCount` to 0 -- **Delete** — remove the `.eml` and `.meta` files permanently +- **View** - see full headers and the provider error from the `.meta` sidecar +- **Retry** - move the file back to `spool/incoming/` and reset `retryCount` to 0 +- **Delete** - remove the `.eml` and `.meta` files permanently ### 6.10 Auto-Purge and Retention @@ -560,9 +581,9 @@ Dispatch manages data growth in two places: spool files on disk and log rows in #### Spool File Purge -`spool/failed/` files older than the configured retention period are deleted by the `PurgeWorker` (default: 30 days). `spool/incoming/` and `spool/processing/` files are never auto-purged — they are active messages. +`spool/failed/` files older than the configured retention period are deleted by the `PurgeWorker` (default: 30 days). `spool/incoming/` and `spool/processing/` files are never auto-purged - they are active messages. -#### relay_log Purge — Time-Based +#### relay_log Purge - Time-Based The `PurgeWorker` `IHostedService` runs every 6 hours (configurable) and deletes `relay_log` rows older than the configured retention period: @@ -580,14 +601,14 @@ WHERE logged_at < DATEADD(DAY, -@RetentionDays, SYSUTCDATETIME()); Deletes run in batches of 1,000 rows with a 100 ms pause between batches to avoid lock contention. -#### relay_log Purge — Size-Based Pressure +#### relay_log Purge - Size-Based Pressure -The purge worker also checks database size on every run and on service startup. If the `DispatchQueue` database reaches **9.5 GB**, it deletes the oldest `relay_log` rows in batches of 500 until the database drops below **9.0 GB**: +The purge worker also checks database size on every run and on service startup. If the `DispatchLog` database reaches **9.5 GB**, it deletes the oldest `relay_log` rows in batches of 500 until the database drops below **9.0 GB**: ```csharp // Priority order for size-based purge (oldest first in each phase): // 1. relay_log rows (most numerous; no raw bytes) -// SQL Server Express hard limit is 10 GB — 9.5 GB trigger leaves 500 MB buffer +// SQL Server Express hard limit is 10 GB - 9.5 GB trigger leaves 500 MB buffer ``` | Database size | Behaviour | @@ -615,11 +636,11 @@ The purge worker also checks database size on every run and on service startup. } ``` -### 6.11 SQL Server — Full Database Schema +### 6.11 SQL Server - Full Database Schema SQL Server holds four tables. There is no `relay_queue` table. -**relays** — named relay configurations: +**relays** - named relay configurations: ```sql CREATE TABLE relays ( @@ -637,7 +658,7 @@ CREATE TABLE relays ( CREATE UNIQUE INDEX IX_relays_default ON relays (is_default) WHERE is_default = 1; ``` -**routing_rules** — ordered routing table: +**routing_rules** - ordered routing table: ```sql CREATE TABLE routing_rules ( @@ -652,7 +673,7 @@ CREATE TABLE routing_rules ( ); ``` -**relay_log** — after-the-fact event history: +**relay_log** - after-the-fact event history: ```sql CREATE TABLE relay_log ( @@ -679,9 +700,9 @@ CREATE TABLE relay_log ( -- Routing relay_id INT NULL REFERENCES relays(id), - relay_name NVARCHAR(128) NULL, -- denormalised — survives relay rename/delete + relay_name NVARCHAR(128) NULL, -- denormalised - survives relay rename/delete routing_rule_id INT NULL REFERENCES routing_rules(id), - routing_rule_name NVARCHAR(128) NULL, -- denormalised — survives rule rename/delete + routing_rule_name NVARCHAR(128) NULL, -- denormalised - survives rule rename/delete routing_matched BIT NOT NULL DEFAULT 0, -- 0 = fell through to default -- Provider outcome @@ -744,7 +765,7 @@ CREATE INDEX IX_relay_log_purge ``` -**relay_counters** — lightweight daily aggregates, always written regardless of log suppression settings: +**relay_counters** - lightweight daily aggregates, always written regardless of log suppression settings: ```sql CREATE TABLE relay_counters ( @@ -772,7 +793,7 @@ WHEN MATCHED THEN UPDATE SET delivered = delivered + 1 WHEN NOT MATCHED THEN INSERT (date, relay_id, delivered) VALUES (src.date, src.relay_id, 1); ``` -The Dashboard reads from `relay_counters` for all counters — never from `relay_log`. This means counters are accurate regardless of whether `relay_log` success logging is enabled or disabled. +The Dashboard reads from `relay_counters` for all counters - never from `relay_log`. This means counters are accurate regardless of whether `relay_log` success logging is enabled or disabled. @@ -785,7 +806,7 @@ CREATE TABLE config ( ); ``` -**api_keys** — HTTP API authentication keys: +**api_keys** - HTTP API authentication keys: ```sql CREATE TABLE api_keys ( @@ -805,7 +826,7 @@ CREATE TABLE api_keys ( CREATE INDEX IX_api_keys_lookup ON api_keys (key_id) WHERE revoked = 0; ``` -**config_smtp_credentials** — SMTP sender allow-list: +**config_smtp_credentials** - SMTP sender allow-list: ```sql CREATE TABLE config_smtp_credentials ( @@ -817,7 +838,7 @@ CREATE TABLE config_smtp_credentials ( ); ``` -**schema_version** — migration tracking: +**schema_version** - migration tracking: ```sql CREATE TABLE schema_version ( @@ -827,45 +848,45 @@ CREATE TABLE schema_version ( ); ``` -### 6.12 SQL Server — Setup +### 6.12 SQL Server - Setup | Item | Detail | |---|---| -| Minimum version | SQL Server Express 2019 or 2022 (free) — full SQL Server editions also supported | -| Linux support | Yes — via the official Microsoft SQL Server for Linux package | -| Database name | `DispatchLog` (renamed from `DispatchQueue` — it is a log store, not a queue) | +| Minimum version | SQL Server Express 2019 or 2022 (free) - full SQL Server editions also supported | +| Linux support | Yes - via the official Microsoft SQL Server for Linux package | +| Database name | `DispatchLog` (renamed from `DispatchQueue` - it is a log store, not a queue) | | Connection string | Written to `appsettings.json` by the bootstrap installer; editable in the web UI | | Schema migration | Applied automatically on startup via embedded SQL scripts | | Auth modes | Windows Auth (Windows only) or SQL Auth username/password (both platforms) | | SQL down behaviour | Mail flows; `relay_log` inserts fire-and-forget (failed inserts logged to file sink; UI dark); `relay_counters` upserts also fire-and-forget; counters may have gaps during outage | -| Setup flow | Handled entirely by the Dispatch Bootstrap Installer — see Sections 11 and 12 | +| Setup flow | Handled entirely by the Dispatch Bootstrap Installer - see Sections 11 and 12 | ## 7. HTTP Ingestion API ### 7.1 Purpose -In addition to accepting mail over SMTP, Dispatch exposes an HTTP API on a dedicated port (default **8081**) that lets developers submit messages with a simple `POST` request — no SMTP client, no MX configuration, no email library required. The API is intentionally similar to Mailgun's `/messages` endpoint so it is familiar to developers already using cloud email providers. +In addition to accepting mail over SMTP, Dispatch exposes an HTTP API on a dedicated port (default **8025**) that lets developers submit messages with a simple `POST` request - no SMTP client, no MX configuration, no email library required. The API is intentionally similar to Mailgun's `/messages` endpoint so it is familiar to developers already using cloud email providers. -The HTTP API and the web UI run on **different ports** with **different authentication**. The web UI (8080) uses optional username/password. The API (8081) uses API keys issued from the web UI. +The HTTP API and the web UI run on **different ports** with **different authentication**. The web UI (8420) requires an admin password (set at install, or via a one-time first-run setup screen). The API (8025) uses API keys issued from the web UI. -All messages received via the HTTP API follow exactly the same path as SMTP messages — written atomically to `spool/incoming/` and `202 Accepted` returned before any database or network call. +All messages received via the HTTP API follow exactly the same path as SMTP messages - written atomically to `spool/incoming/` and `202 Accepted` returned before any database or network call. ### 7.2 Port and Binding | Setting | Default | Config key | |---|---|---| -| API port | 8081 | `api.port` | -| Bind address | 0.0.0.0 (all interfaces) | — (fixed, not configurable) | +| API port | 8025 | `api.port` | +| Bind address | 0.0.0.0 (all interfaces) | - (fixed, not configurable) | | Allowed source IPs / CIDRs | `127.0.0.1/32` | `api.allowed_cidrs` | | Max message size (global ceiling) | No limit (0) | `api.max_message_bytes` | | Rate limit (per key) | 100 req/min | `api.rate_limit_per_key` | -The API listener binds to all interfaces. Access control is enforced at the **application layer** via an IP/CIDR allow-list. Requests from IPs outside the allow-list receive `403 Forbidden` — never `401` — so the response does not leak whether a valid API key was present. Every denied request is written to `relay_log` as a `Denied` event with the source IP, key ID prefix (if a key was provided), and reason, making all rejections visible in the web UI Message Log. +The API listener binds to all interfaces. Access control is enforced at the **application layer** via an IP/CIDR allow-list. Requests from IPs outside the allow-list receive `403 Forbidden` - never `401` - so the response does not leak whether a valid API key was present. Every denied request is written to `relay_log` as a `Denied` event with the source IP, key ID prefix (if a key was provided), and reason, making all rejections visible in the web UI Message Log. -The Windows firewall rule (Section 14.3a) opens the port to `any` — it is a port-opener, not a security boundary. The CIDR allow-list in the application is the actual access control. +The Windows firewall rule (Section 14.3a) opens the port to `any` - it is a port-opener, not a security boundary. The CIDR allow-list in the application is the actual access control. -### 7.3 Authentication — API Keys +### 7.3 Authentication - API Keys Every API request must include an API key in the `Authorization` header: @@ -879,7 +900,7 @@ Requests without a valid key receive `401 Unauthorized`. Requests over the rate ### 7.4 Endpoints -#### POST /api/v1/messages — Send a message +#### POST /api/v1/messages - Send a message Accepts `multipart/form-data` (for attachments) or `application/json` (simple messages). @@ -887,14 +908,14 @@ Accepts `multipart/form-data` (for attachments) or `application/json` (simple me | Field | Required | Description | |---|---|---| -| `from` | Yes | Sender address — `Name ` or `email` | -| `to` | Yes | Recipient(s) — repeat field or comma-separated | +| `from` | Yes | Sender address - `Name ` or `email` | +| `to` | Yes | Recipient(s) - repeat field or comma-separated | | `cc` | No | CC recipients | | `bcc` | No | BCC recipients | | `subject` | Yes | Message subject | | `text` | No* | Plain-text body | | `html` | No* | HTML body | -| `attachment` | No | File attachment(s) — repeat field for multiple | +| `attachment` | No | File attachment(s) - repeat field for multiple | | `h:X-Custom-Header` | No | Any field prefixed `h:` is added as a message header | | `o:tag` | No | Tags stored in the log entry for filtering (repeat for multiple) | @@ -902,7 +923,7 @@ Accepts `multipart/form-data` (for attachments) or `application/json` (simple me **Example (curl):** ```bash -curl -X POST http://localhost:8081/api/v1/messages \ +curl -X POST http://localhost:8025/api/v1/messages \ -H "Authorization: Bearer dsp_live_a1b2c3d4e5f6" \ -F from="Dispatch " \ -F to="user@example.com" \ @@ -911,7 +932,7 @@ curl -X POST http://localhost:8081/api/v1/messages \ -F text="Welcome!" ``` -**Success response — `202 Accepted`:** +**Success response - `202 Accepted`:** ```json { "id": "spl_a1b2c3d4", @@ -936,7 +957,7 @@ The `id` is the spool file UUID and can be used to look up the delivery status l } ``` -#### GET /api/v1/messages/{id} — Check delivery status +#### GET /api/v1/messages/{id} - Check delivery status ``` GET /api/v1/messages/spl_a1b2c3d4 @@ -956,7 +977,7 @@ Response: Status values: `queued` | `processing` | `delivered` | `retrying` | `failed` -#### GET /api/v1/messages — List recent messages for this key +#### GET /api/v1/messages - List recent messages for this key ``` GET /api/v1/messages?limit=20&status=failed @@ -967,20 +988,20 @@ Returns the last N messages submitted with this API key. Useful for debugging. ### 7.5 How API Messages Enter the Spool -The HTTP handler builds a `MimeMessage` from the POST fields using MimeKit, then serialises it to RFC 5322 bytes and writes it to `spool/incoming/` — identical to what the SMTP listener does. From that point the message is indistinguishable from an SMTP message. +The HTTP handler builds a `MimeMessage` from the POST fields using MimeKit, then serialises it to RFC 5322 bytes and writes it to `spool/incoming/` - identical to what the SMTP listener does. From that point the message is indistinguishable from an SMTP message. ```csharp public class ApiMessageHandler(SpoolDirectory spool, IConfigCache config) { public async Task HandleAsync(SendMessageRequest req, CancellationToken ct) { - // Validate API key — already done by middleware, key attached to HttpContext + // Validate API key - already done by middleware, key attached to HttpContext // Build MimeMessage from request fields var mime = BuildMimeMessage(req); var id = Guid.NewGuid(); var emlPath = spool.IncomingPath(id); - // Serialise to RFC 5322 bytes — same format as SMTP + // Serialise to RFC 5322 bytes - same format as SMTP using var stream = File.OpenWrite(emlPath); await mime.WriteToAsync(stream, ct); @@ -1012,7 +1033,7 @@ CREATE TABLE api_keys ( ); ``` -Keys are generated as `dsp_live_{32 random URL-safe chars}`. The `key_id` is the first 12 characters (shown in the UI for identification). The full key is shown once on creation and never again — Dispatch stores only the bcrypt hash. +Keys are generated as `dsp_live_{32 random URL-safe chars}`. The `key_id` is the first 12 characters (shown in the UI for identification). The full key is shown once on creation and never again - Dispatch stores only the bcrypt hash. ### 7.7 Key Verification @@ -1054,13 +1075,13 @@ public class ApiKeyMiddleware(IApiKeyRepository keys) : IMiddleware } ``` -Key verification does one SQL lookup (by `key_id` prefix, which is indexed) then a bcrypt compare. The bcrypt compare is the expensive step — cache the full key object in a short-lived memory cache (TTL: 30 seconds) keyed by the raw token to avoid bcrypt on every request under normal load. +Key verification does one SQL lookup (by `key_id` prefix, which is indexed) then a bcrypt compare. The bcrypt compare is the expensive step - cache the full key object in a short-lived memory cache (TTL: 30 seconds) keyed by the raw token to avoid bcrypt on every request under normal load. ```sql CREATE INDEX IX_api_keys_key_id ON api_keys (key_id) WHERE revoked = 0; ``` -### 7.8 Web UI — Settings: API Keys +### 7.8 Web UI - Settings: API Keys A dedicated **API Keys** page under Settings: @@ -1106,26 +1127,26 @@ Added to the `config` table: | Key | Default | Encrypted | |---|---|---| | `api.enabled` | `true` | No | -| `api.port` | `8081` | No | +| `api.port` | `8025` | No | | `api.max_message_bytes` | `0` (no limit) | No | | `api.rate_limit_per_key` | `100` | No | ### 7.10 Firewall Rule (Windows MSI) -The MSI creates a firewall rule that opens port 8081 to all addresses. Scope is intentionally `any` — the firewall is a port-opener only. Access control (which IPs may actually use the API) is enforced by the application-layer CIDR allow-list (`api.allowed_cidrs`), which is configurable in the web UI without touching firewall rules. +The MSI creates a firewall rule that opens port 8025 to all addresses. Scope is intentionally `any` - the firewall is a port-opener only. Access control (which IPs may actually use the API) is enforced by the application-layer CIDR allow-list (`api.allowed_cidrs`), which is configurable in the web UI without touching firewall rules. | Rule name | Direction | Protocol | Port | Scope | |---|---|---|---|---| -| `Dispatch API` | Inbound | TCP | 8081 | `any` | +| `Dispatch API` | Inbound | TCP | 8025 | `any` | -This matches the design of the SMTP rules (25, 587) — the OS firewall opens the port, the application decides which source IPs are allowed. Denied connections are logged and visible in the UI. +This matches the design of the SMTP rules (25, 587) - the OS firewall opens the port, the application decides which source IPs are allowed. Denied connections are logged and visible in the UI. --- ## 8. Upstream Relay Providers -Each provider type implements `IRelayProvider`. Provider instances are not singletons — a new instance is built from a `RelayConfig` (loaded from the `relays` table) each time a message is dispatched. This allows multiple named relays of the same provider type to coexist. +Each provider type implements `IRelayProvider`. Provider instances are not singletons - a new instance is built from a `RelayConfig` (loaded from the `relays` table) each time a message is dispatched. This allows multiple named relays of the same provider type to coexist. ```csharp public interface IRelayProvider @@ -1135,7 +1156,7 @@ public interface IRelayProvider } ``` -`IRelayProviderFactory.Build(RelayConfig config)` reads the provider type and credentials from a `RelayConfig` and returns the appropriate `IRelayProvider` implementation. The size limit lives on `RelayConfig.EffectiveMaxMessageBytes` — a computed property that returns `relay.max_message_bytes` if set, or the provider type's built-in default otherwise. See Section 14 (Operational Concerns) for full size enforcement details. +`IRelayProviderFactory.Build(RelayConfig config)` reads the provider type and credentials from a `RelayConfig` and returns the appropriate `IRelayProvider` implementation. The size limit lives on `RelayConfig.EffectiveMaxMessageBytes` - a computed property that returns `relay.max_message_bytes` if set, or the provider type's built-in default otherwise. See Section 14 (Operational Concerns) for full size enforcement details. ### 8.1 Mailgun @@ -1169,14 +1190,14 @@ public interface IRelayProvider | Field | Detail | |---|---| -| Mechanism | MailKit SmtpClient — relay to any SMTP endpoint | +| Mechanism | MailKit SmtpClient - relay to any SMTP endpoint | | Auth | PLAIN / LOGIN / OAuth2 (configurable) | | Required settings | Host, Port, Username, Password, TLS mode | | Notes | Use for AWS SES SMTP, Office 365, Postfix, or any smart host | -### 8.5 None / Dev Mode +### 8.5 Local / Developer Mode -A no-op provider that accepts and discards all messages, logging them as if delivered. All message content is still visible in the UI Message Log. Ideal for local development so no real emails are sent. +A provider that never delivers externally. It captures each message to `spool/captured/` (viewable in the **Local Inbox** page) and logs it as delivered. Ideal for local development so no real emails are sent. (The out-of-the-box default relay is *Unconfigured* - it refuses to relay until a provider is chosen, so mail is never silently delivered or discarded.) --- @@ -1186,7 +1207,7 @@ A no-op provider that accepts and discards all messages, logging them as if deli | Layer | Technology | |---|---| -| Backend host | ASP.NET Core 9 minimal API (in same process as relay) | +| Backend host | ASP.NET Core 10 minimal API (in same process as relay) | | Real-time updates | SignalR WebSocket hub | | Frontend framework | React 18 + TypeScript | | Build tool | Vite (output embedded via EmbeddedFileProvider) | @@ -1194,23 +1215,23 @@ A no-op provider that accepts and discards all messages, logging them as if deli | Charts / stats | Recharts | | API client | Fetch API + TanStack Query | -The React `dist/` output is embedded into `Dispatch.Web.dll` at compile time via an MSBuild `EmbeddedResource` target. At runtime, ASP.NET Core's static file middleware serves this embedded content — users need no separate web server, Node.js, or file deployment. +The React `dist/` output is embedded into `Dispatch.Web.dll` at compile time via an MSBuild `EmbeddedResource` target. At runtime, ASP.NET Core's static file middleware serves this embedded content - users need no separate web server, Node.js, or file deployment. ### 9.2 Pages #### Dashboard -- **Live counters** — messages received today, delivered, failed, denied, in spool (pending + processing). Counters come from `relay_counters` (the daily aggregate table) — a narrow O(1) query on today's date, always accurate regardless of whether `relay_log` success logging is enabled. Refreshed every 10 seconds via polling. -- **Throughput sparkline** — messages delivered per minute, last 60 minutes. Because `relay_counters` is daily only, the sparkline reads from `relay_log` when success logging is on, or from a dedicated `relay_counters_minute` in-memory ring buffer (held in `SpoolWorkerPool`, written on every delivery) when success logging is off. The server returns 60 pre-aggregated data points via `GET /api/stats/throughput`. Updated every 60 seconds. -- **Active relay badges** — one badge per enabled relay showing its message count for today and status (green = last dispatch succeeded, red = last dispatch failed). -- **Recent activity feed** — last 20 log entries, streamed via SignalR. Under high volume (> 10 messages/second), the SignalR push is **throttled and batched**: the server collects entries for up to 500 ms and sends them as a single array event, not one event per message. The feed shows the 20 most recent at any time, dropping older ones as new ones arrive. -- **Spool health** — count of files in `incoming/`, `processing/`, `failed/` read directly from the filesystem (no SQL). Updated every 5 seconds. +- **Live counters** - messages received today, delivered, failed, denied, in spool (pending + processing). Counters come from `relay_counters` (the daily aggregate table) - a narrow O(1) query on today's date, always accurate regardless of whether `relay_log` success logging is enabled. Refreshed every 10 seconds via polling. +- **Throughput sparkline** - messages delivered per minute, last 60 minutes. Because `relay_counters` is daily only, the sparkline reads from `relay_log` when success logging is on, or from a dedicated `relay_counters_minute` in-memory ring buffer (held in `SpoolWorkerPool`, written on every delivery) when success logging is off. The server returns 60 pre-aggregated data points via `GET /api/stats/throughput`. Updated every 60 seconds. +- **Active relay badges** - one badge per enabled relay showing its message count for today and status (green = last dispatch succeeded, red = last dispatch failed). +- **Recent activity feed** - last 20 log entries, streamed via SignalR. Under high volume (> 10 messages/second), the SignalR push is **throttled and batched**: the server collects entries for up to 500 ms and sends them as a single array event, not one event per message. The feed shows the 20 most recent at any time, dropping older ones as new ones arrive. +- **Spool health** - count of files in `incoming/`, `processing/`, `failed/` read directly from the filesystem (no SQL). Updated every 5 seconds. #### Message Log The Message Log is designed to handle millions of rows without degrading. Every design decision below is driven by that constraint. -**Filter bar** — all filters are applied simultaneously; the result set updates on each change with a 300 ms debounce: +**Filter bar** - all filters are applied simultaneously; the result set updates on each change with a 300 ms debounce: | Filter | Control | Notes | |---|---|---| @@ -1221,7 +1242,7 @@ The Message Log is designed to handle millions of rows without degrading. Every | Ingest source | Toggle: All · SMTP · API | Filters on `ingest_source` | | Sender domain | Text field | Exact match on `from_domain`; indexed | | Recipient domain | Text field | Exact match on `to_domain`; indexed | -| Tag | Text field | Filters using `JSON_VALUE`; slower — shown with a ⚠ indicator | +| Tag | Text field | Filters using `JSON_VALUE`; slower - shown with a ⚠ indicator | | Subject | Text field | Full-text search; shown with a ⚠ indicator and note that it may be slow | | API key | Dropdown (when ingest source = API) | Filters on `api_key_id` | @@ -1233,99 +1254,99 @@ Fields marked ⚠ do not have covering indexes and may be slower on large datase |---|---|---| | Timestamp | `logged_at` | ✓ (default desc) | | Status | `status` | ✓ | -| From | `from_address` | — | -| To | `to_addresses` (first) | — | -| Subject | `subject` (truncated to 60 chars) | — | +| From | `from_address` | - | +| To | `to_addresses` (first) | - | +| Subject | `subject` (truncated to 60 chars) | - | | Relay | `relay_name` | ✓ | -| Rule | `routing_rule_name` or "Default" | — | +| Rule | `routing_rule_name` or "Default" | - | | Provider | `provider` | ✓ | | Duration | `duration_ms` | ✓ | | Size | `size_bytes` | ✓ | | Source | `ingest_source` | ✓ | -| Retry # | `retry_attempt` | — | +| Retry # | `retry_attempt` | - | Hidden by default (toggleable): Tag, Source IP, API Key, Provider Message ID. -**Pagination** — keyset (cursor-based), not `OFFSET/FETCH`: +**Pagination** - keyset (cursor-based), not `OFFSET/FETCH`: - Page size: 50 rows (configurable 25 / 50 / 100) -- "Load more" button appends the next page below current results — no page numbers, no "go to page N" +- "Load more" button appends the next page below current results - no page numbers, no "go to page N" - Cursor is the `(logged_at, id)` tuple of the last visible row, passed as an opaque token - Why: `OFFSET 5000 FETCH 50` requires SQL Server to scan and discard 5,000 rows first; cursor pagination is O(1) regardless of depth -**No total row count displayed** — computing `COUNT(*)` on a filtered `relay_log` with millions of rows is expensive. The UI shows "Showing 50 of many" or "Showing 12" (when fewer than a page returned) — not "Page 3 of 14,829". +**No total row count displayed** - computing `COUNT(*)` on a filtered `relay_log` with millions of rows is expensive. The UI shows "Showing 50 of many" or "Showing 12" (when fewer than a page returned) - not "Page 3 of 14,829". -**Dashboard counters come from `relay_counters`, not `relay_log`** — so they are always accurate even when success logging is disabled. The Message Log "Showing N" count is derived from the query result only — never from a separate `COUNT(*)` call. +**Dashboard counters come from `relay_counters`, not `relay_log`** - so they are always accurate even when success logging is disabled. The Message Log "Showing N" count is derived from the query result only - never from a separate `COUNT(*)` call. -**Live update stream** — new log entries are pushed via SignalR but **not** prepended to the table automatically. Instead: -- A dismissable banner appears at the top: **"47 new messages since you loaded — click to refresh"** +**Live update stream** - new log entries are pushed via SignalR but **not** prepended to the table automatically. Instead: +- A dismissable banner appears at the top: **"47 new messages since you loaded - click to refresh"** - This avoids rows jumping around while the user is reading, and avoids rendering churn under high volume - The banner count increments in real time; clicking it reloads the first page with the current filters -**Row detail panel** — clicking any row expands an inline panel below it (not a modal) showing: +**Row detail panel** - clicking any row expands an inline panel below it (not a modal) showing: - Full envelope: From, all To/CC/BCC, Message-ID, Received-At - Routing: rule name, pattern that matched, relay used, whether it was the default - Provider outcome: full provider response, provider's message ID, HTTP status or SMTP response code - Retry history: timeline of all attempts for this spool ID (queried from other `relay_log` rows with same `spool_id`) - Tags: all tags as chips - Source: SMTP or API, source IP, API key name if applicable -- Actions: **Retry** (if status = Failed — moves spool file back to `incoming/`), **Copy spool ID** +- Actions: **Retry** (if status = Failed - moves spool file back to `incoming/`), **Copy spool ID** -**Virtualised rendering** — the row list uses a virtual scroll window (TanStack Virtual) so only ~20 rows are in the DOM at any time regardless of how many have been loaded. "Load more" appends to the in-memory list; the virtual window handles rendering. +**Virtualised rendering** - the row list uses a virtual scroll window (TanStack Virtual) so only ~20 rows are in the DOM at any time regardless of how many have been loaded. "Load more" appends to the in-memory list; the virtual window handles rendering. -**Export** — exporting is async to handle large result sets: +**Export** - exporting is async to handle large result sets: - User clicks **Export CSV** with current filters active - Server starts a background job, immediately returns a job ID - UI polls `GET /api/messages/export/{jobId}` every 2 seconds - Progress shown: "Exporting... 4,231 / ~12,000 rows" -- When complete, a download link appears — file served from a temp path, deleted after 10 minutes +- When complete, a download link appears - file served from a temp path, deleted after 10 minutes - Export is capped at **100,000 rows** per job; a warning is shown if the filtered result set exceeds this -**Empty state** — when no rows match the current filters, show the active filters as chips with a "Clear all filters" link, not just a generic "No results" message. +**Empty state** - when no rows match the current filters, show the active filters as chips with a "Clear all filters" link, not just a generic "No results" message. -#### Settings — Relays +#### Settings - Relays - Table of all named relay configs: name, provider type, default indicator, enabled status, `max_concurrency`, and a live **In Flight** counter showing how many dispatches are currently active against that relay -- **+ Add Relay** — opens a form with name field, provider dropdown, provider-specific credential fields, max concurrency slider (1–32, default 4; 0 = unlimited), max message size override (0 = use provider default), and a Test button -- **Edit** per relay — same form; includes Set as Default and Delete (disabled if default or referenced by rules); changing `max_concurrency` or `max_message_bytes` takes effect immediately on the next file claim with no restart +- **+ Add Relay** - opens a form with name field, provider dropdown, provider-specific credential fields, max concurrency slider (1–32, default 4; 0 = unlimited), max message size override (0 = use provider default), and a Test button +- **Edit** per relay - same form; includes Set as Default and Delete (disabled if default or referenced by rules); changing `max_concurrency` or `max_message_bytes` takes effect immediately on the next file claim with no restart - Default relay shown with a ★ indicator; cannot be deleted -The **In Flight** counter per relay is served by `GET /api/relays/concurrency` — a lightweight endpoint that reads the current `SemaphoreSlim.CurrentCount` for each relay from the in-memory pool (not a SQL query). +The **In Flight** counter per relay is served by `GET /api/relays/concurrency` - a lightweight endpoint that reads the current `SemaphoreSlim.CurrentCount` for each relay from the in-memory pool (not a SQL query). -#### Settings — Routing Rules +#### Settings - Routing Rules - Drag-to-reorder ordered rule list showing recipient pattern, sender pattern, and target relay per rule -- **+ Add Rule** — name, recipient pattern, sender pattern (at least one required), relay selector -- **Simulate** field — enter a from/to address to see which rule would match and which relay would be used +- **+ Add Rule** - name, recipient pattern, sender pattern (at least one required), relay selector +- **Simulate** field - enter a from/to address to see which rule would match and which relay would be used - Default relay shown at the bottom as the non-editable catch-all with a Change link -#### Settings — API Keys +#### Settings - API Keys - Table of all API keys: name, key ID prefix (first 12 chars), created date, last used, message count, revoked status -- **+ Create New Key** button — prompts for name and optional per-key rate limit; shows full key once with a Copy button; warns it will not be shown again -- **Revoke** button per key — marks revoked immediately; key rejected on next request -- **Show revoked** toggle — revoked keys hidden by default but retained for audit +- **+ Create New Key** button - prompts for name and optional per-key rate limit; shows full key once with a Copy button; warns it will not be shown again +- **Revoke** button per key - marks revoked immediately; key rejected on next request +- **Show revoked** toggle - revoked keys hidden by default but retained for audit - Per-key message counts and last-used timestamps updated in real time -#### Settings — SMTP Listener +#### Settings - SMTP Listener - Inbound ports (comma-separated) -- Allowed source IPs / CIDRs — text area, one entry per line (e.g. `192.168.1.0/24`, `10.0.0.5/32`); default `127.0.0.1/32`; set to `0.0.0.0/0` to accept from anywhere (warn if set with Require AUTH disabled) +- Allowed source IPs / CIDRs - text area, one entry per line (e.g. `192.168.1.0/24`, `10.0.0.5/32`); default `127.0.0.1/32`; set to `0.0.0.0/0` to accept from anywhere (warn if set with Require AUTH disabled) - Max message size global ceiling (advertised in EHLO SIZE; 0 = no global limit; per-relay limits set on the Relays page) - Require AUTH toggle + credential manager (add/remove username + password pairs) - TLS certificate file path + passphrase - Connection timeout and max concurrent connections -#### Settings — Relay Provider +#### Settings - Relay Provider - Provider selector dropdown (Mailgun / SendGrid / Azure Communication Services / SMTP / None) - Provider-specific fields appear dynamically based on selection - All API keys and passwords stored AES-256 encrypted in the `config` table in SQL Server -- **Send Test Email** button — runs a full provider test using the current (unsaved) form values; streams results live in an inline log panel (see Section 9) +- **Send Test Email** button - runs a full provider test using the current (unsaved) form values; streams results live in an inline log panel (see Section 9) - Retry policy controls (attempts, delay intervals) -#### Settings — Logging +#### Settings - Logging Controls which events are written to the `relay_log` database table. Counters (`relay_counters`) and the Serilog file log are always written regardless of these settings. @@ -1333,39 +1354,39 @@ Controls which events are written to the `relay_log` database table. Counters (` |---|---|---| | Log successful deliveries | On | Turn off to suppress `Delivered` rows at high volume | | Log retry attempts | On | Turn off to hide intermediate retries when success logging is also off | -| Log denied connections | On | Recommended to keep on — security audit trail | +| Log denied connections | On | Recommended to keep on - security audit trail | **Behaviour when "Log successful deliveries" is off:** - No `relay_log` row is written for `Delivered` events -- `relay_counters.delivered` is still incremented — Dashboard counters remain accurate +- `relay_counters.delivered` is still incremented - Dashboard counters remain accurate - The throughput sparkline uses the in-memory `relay_counters_minute` ring buffer instead of `relay_log` -- If a message is retried and eventually succeeds, the intermediate `Retrying` rows are suppressed too (controlled by the retry logging toggle) — no orphaned retry entries without a final delivered row +- If a message is retried and eventually succeeds, the intermediate `Retrying` rows are suppressed too (controlled by the retry logging toggle) - no orphaned retry entries without a final delivered row - `Failed` events are always logged regardless of this setting - `Denied` events are always logged regardless of this setting (unless explicitly disabled above) -- The Message Log will show only failures and denied connections — this is intentional and shown as a banner: **"Success logging is disabled — only failures and denials are shown"** +- The Message Log will show only failures and denied connections - this is intentional and shown as a banner: **"Success logging is disabled - only failures and denials are shown"** -#### Settings — Storage & Retention +#### Settings - Storage & Retention - Current database size with a per-table breakdown (`spool/` directory / `relay_log`) - Row counts by status / event type with a colour-coded breakdown chart - Retention sliders: delivered queue rows, failed queue rows, log events (with Advanced toggle for per-event-type overrides) -- **Run Purge Now** button — triggers an immediate out-of-schedule purge with a live row-count progress display -- **Purge History** table — last 10 purge runs: timestamp, rows deleted per table, duration +- **Run Purge Now** button - triggers an immediate out-of-schedule purge with a live row-count progress display +- **Purge History** table - last 10 purge runs: timestamp, rows deleted per table, duration -#### Settings — HTTP API +#### Settings - HTTP API - Enable/disable the HTTP API -- API port (default 8081) -- Allowed source IPs / CIDRs — same format as SMTP listener; default `127.0.0.1/32` +- API port (default 8025) +- Allowed source IPs / CIDRs - same format as SMTP listener; default `127.0.0.1/32` - TLS certificate path + passphrase (enables HTTPS; required warning shown when CIDR is wider than localhost without TLS) - Global rate limit per key (requests per minute; per-key overrides set on the API Keys page) - Max message size global ceiling for API submissions (0 = no global limit; per-relay limits enforced after routing) -#### Settings — Web UI +#### Settings - Web UI -- Web UI port (default 8080) -- Allowed source IPs / CIDRs — default `127.0.0.1/32`; set to `0.0.0.0/0` for access from any machine +- Web UI port (default 8420) +- Allowed source IPs / CIDRs - default `127.0.0.1/32`; set to `0.0.0.0/0` for access from any machine - TLS certificate path + passphrase (enables HTTPS; required warning shown when auth is on without TLS) - Enable/disable UI password protection (username + bcrypt-hashed password) - Session timeout in minutes (default 480 = 8 hours) @@ -1375,7 +1396,7 @@ Controls which events are written to the `relay_log` database table. Counters (` - Service uptime, .NET version, OS, Dispatch version - Log file location with download link -- Restart service button (graceful — drains queue before restarting listener) +- Restart service button (graceful - drains queue before restarting listener) - Links to GitHub, documentation, licence ### 9.3 REST API Endpoints @@ -1394,12 +1415,12 @@ Controls which events are written to the `relay_log` database table. Counters (` | `PUT /api/routing/rules/{id}` | Update a rule | | `PUT /api/routing/rules/reorder` | Reorder rules (body: array of IDs) | | `DELETE /api/routing/rules/{id}` | Delete a rule | -| `POST /api/routing/simulate` | Simulate routing for a given from/to — returns matched rule and relay | -| `GET /health` | Health check — no auth required | +| `POST /api/routing/simulate` | Simulate routing for a given from/to - returns matched rule and relay | +| `GET /health` | Health check - no auth required | | `GET /api/stats` | Counters for the dashboard | -| `GET /api/messages` | Paginated message log — see query parameters below | +| `GET /api/messages` | Paginated message log - see query parameters below | | `GET /api/messages/{id}` | Full detail for a single log entry including retry history | -| `POST /api/messages/export` | Start async CSV export — returns `{ jobId }` | +| `POST /api/messages/export` | Start async CSV export - returns `{ jobId }` | | `GET /api/messages/export/{jobId}` | Poll export progress; returns download URL when complete | | `GET /api/stats` | Dashboard counters (today totals, spool counts) | | `GET /api/stats/throughput` | 60 one-minute delivery buckets for sparkline chart | @@ -1411,11 +1432,11 @@ Controls which events are written to the `relay_log` database table. Counters (` | `POST /api/service/drain` | Wait for all Processing rows to settle; returns when queue is clear or timeout reached | | `POST /api/service/restart` | Graceful relay restart (drains queue first) | | `GET /api/logs/download` | Download current log file | -| `WS /hub/logs` | SignalR hub — streams live log events to the browser | +| `WS /hub/logs` | SignalR hub - streams live log events to the browser | | `POST /api/config/test-provider` | Run a provider test using supplied (unsaved) credentials; returns a test-run ID | | `GET /api/config/test-provider/{runId}` | Poll result and log lines for a completed or in-progress test run | -| `WS /hub/test-provider` | SignalR channel — streams live log lines for an active provider test | +| `WS /hub/test-provider` | SignalR channel - streams live log lines for an active provider test | --- @@ -1423,7 +1444,7 @@ Controls which events are written to the `relay_log` database table. Counters (` ### 10.1 Overview -Dispatch supports multiple named relay configurations. Every inbound message — whether from SMTP or the HTTP API — is evaluated against an ordered routing rule table to determine which relay to use. If no rule matches, the **default relay** is used. +Dispatch supports multiple named relay configurations. Every inbound message - whether from SMTP or the HTTP API - is evaluated against an ordered routing rule table to determine which relay to use. If no rule matches, the **default relay** is used. ``` Inbound message (from / to) @@ -1443,7 +1464,7 @@ Inbound message (from / to) ### 10.2 Named Relays -A **relay** is a named, independently configured provider instance. Each relay has its own credentials, provider type, and settings. Multiple relays of the same provider type are allowed — for example, two Mailgun accounts, one for US and one for EU. +A **relay** is a named, independently configured provider instance. Each relay has its own credentials, provider type, and settings. Multiple relays of the same provider type are allowed - for example, two Mailgun accounts, one for US and one for EU. **relay table schema:** @@ -1465,13 +1486,13 @@ CREATE TABLE relays ( -- e.g. relay:1:mailgun.api_key, relay:1:mailgun.domain, relay:1:mailgun.region ``` -Provider credentials for each relay are stored in the existing `config` table using the key prefix `relay:{id}:` — for example `relay:3:mailgun.api_key`. This reuses the existing encryption infrastructure without a separate credentials table. +Provider credentials for each relay are stored in the existing `config` table using the key prefix `relay:{id}:` - for example `relay:3:mailgun.api_key`. This reuses the existing encryption infrastructure without a separate credentials table. **The default relay:** - Exactly one relay has `is_default = 1` at all times -- On first run, a single relay named "Default" is created (provider: None) — the administrator edits it to set their provider -- The default relay cannot be deleted — only edited or replaced (setting another relay as default demotes the current one automatically) +- On first run, a single relay named "Default" is created (provider: None) - the administrator edits it to set their provider +- The default relay cannot be deleted - only edited or replaced (setting another relay as default demotes the current one automatically) - The default relay is used when no routing rule matches ### 10.3 Routing Rules @@ -1497,7 +1518,7 @@ At least one of `recipient_pattern` or `sender_pattern` must be non-NULL. A rule ### 10.4 Pattern Syntax -Patterns match against the domain part of the email address (the part after `@`). The full address is never matched — routing is domain-based, not address-based. +Patterns match against the domain part of the email address (the part after `@`). The full address is never matched - routing is domain-based, not address-based. | Pattern | Matches | Example | |---|---|---| @@ -1535,7 +1556,7 @@ Fall through → use default relay | 10 | `*.acme.com` | `NULL` | Mailgun-EU | | 20 | `NULL` | `app.myco.com` | SendGrid-Transactional | | 30 | `staging.myco.com` | `NULL` | None (dev) | -| — | (default) | — | Mailgun-US | +| - | (default) | - | Mailgun-US | - `from=noreply@app.myco.com, to=user@billing.acme.com` → rule 10 (recipient `*.acme.com` matches) → **Mailgun-EU** - `from=noreply@app.myco.com, to=user@gmail.com` → rule 10 no (gmail ≠ *.acme.com), rule 20 yes (sender matches) → **SendGrid-Transactional** @@ -1544,7 +1565,7 @@ Fall through → use default relay ### 10.6 Multi-Recipient Messages -A message with multiple `To` recipients is evaluated once using the **first recipient's domain**. Dispatch does not split a message and send it via different relays per-recipient — that would break threading and MIME integrity. If splitting is needed, the sender should send separate messages per recipient domain. +A message with multiple `To` recipients is evaluated once using the **first recipient's domain**. Dispatch does not split a message and send it via different relays per-recipient - that would break threading and MIME integrity. If splitting is needed, the sender should send separate messages per recipient domain. The chosen relay is recorded in the `relay_log` entry so it is visible in the Message Log. @@ -1615,7 +1636,7 @@ var provider = _providerFactory.Build(relay); var result = await provider.SendAsync(message, ct); ``` -### 10.8 Web UI — Relays Page +### 10.8 Web UI - Relays Page A **Relays** page under Settings lists all named relay configurations: @@ -1637,10 +1658,10 @@ Each relay edit form shows the provider-specific credential fields (same as the - Name field - Enable / disable toggle - **Set as Default** button (demotes current default) -- **Test** button — runs the same provider test as Section 10 (Provider Testing) against this specific relay -- **Delete** button — disabled if the relay is the default or is referenced by any routing rule +- **Test** button - runs the same provider test as Section 10 (Provider Testing) against this specific relay +- **Delete** button - disabled if the relay is the default or is referenced by any routing rule -### 10.9 Web UI — Routing Rules Page +### 10.9 Web UI - Routing Rules Page A **Routing Rules** page under Settings, below Relays: @@ -1663,7 +1684,7 @@ A **Routing Rules** page under Settings, below Relays: - Rows are drag-to-reorder; priority numbers are reassigned automatically on drop - Each row shows: recipient pattern, sender pattern, target relay, enabled toggle, edit/delete - The default relay is shown at the bottom as a non-draggable catch-all -- **Simulate** button — enter a from/to address and see which rule would match and which relay would be used, without sending anything +- **Simulate** button - enter a from/to address and see which rule would match and which relay would be used, without sending anything ### 10.10 REST API Endpoints @@ -1688,7 +1709,7 @@ A **Routing Rules** page under Settings, below Relays: | `PUT /api/routing/rules/{id}` | Update rule patterns, relay, or enabled state | | `PUT /api/routing/rules/reorder` | Reorder rules (body: array of IDs in new priority order) | | `DELETE /api/routing/rules/{id}` | Delete a rule | -| `POST /api/routing/simulate` | Simulate routing for a given from/to — returns matched rule and relay | +| `POST /api/routing/simulate` | Simulate routing for a given from/to - returns matched rule and relay | ### 10.11 relay_log Changes @@ -1707,9 +1728,9 @@ The Message Log UI adds a **Relay** column so administrators can see at a glance ### 11.1 Purpose -Before saving credentials for any upstream provider, the user needs to verify they work — and see exactly what happened if they don't. This section specifies a self-contained test flow with a dedicated live log view, separate from the main message log. +Before saving credentials for any upstream provider, the user needs to verify they work - and see exactly what happened if they don't. This section specifies a self-contained test flow with a dedicated live log view, separate from the main message log. -### 11.2 UI — Settings: Relay Provider Page +### 11.2 UI - Settings: Relay Provider Page The **Relay Provider** settings page is extended with a test panel below the credential fields: @@ -1743,13 +1764,13 @@ The **Relay Provider** settings page is extended with a test panel below the cre **Behaviour:** -- The credentials used for the test are taken directly from the form fields — they do **not** need to be saved first +- The credentials used for the test are taken directly from the form fields - they do **not** need to be saved first - The **Test recipient** field defaults to the address stored in web UI settings (configurable); the user can override it per-test - Clicking **Send Test Email** starts the test, disables the button, and opens the Test Log panel if it is not already visible - Log lines stream in real time via SignalR as the test executes - On completion the last line shows ✓ SUCCESS or ✗ FAILED in green/red with elapsed time - **Clear** wipes the log panel; the test can be re-run any number of times without saving -- The Save Settings button is independent — the user can test, adjust, re-test, then save +- The Save Settings button is independent - the user can test, adjust, re-test, then save ### 11.3 Test Message Content @@ -1759,10 +1780,10 @@ The test sends a real email through the provider. The message is constructed by |---|---| | From | `Dispatch Test ` | | To | The address in the Test recipient field | -| Subject | `Dispatch provider test — {provider name} — {timestamp UTC}` | +| Subject | `Dispatch provider test - {provider name} - {timestamp UTC}` | | Body (plain) | Confirmation that the provider credentials are working, Dispatch version, timestamp | | Body (HTML) | Same content in a minimal HTML wrapper | -| Custom header | `X-Dispatch-Test: true` — allows the recipient to filter test messages | +| Custom header | `X-Dispatch-Test: true` - allows the recipient to filter test messages | ### 11.4 REST API @@ -1810,7 +1831,7 @@ Response: } ``` -**SignalR hub — `TestProviderLogLine` event:** +**SignalR hub - `TestProviderLogLine` event:** The `/hub/test-provider` hub pushes one event per log line as it is produced: @@ -1854,7 +1875,7 @@ public class ProviderTestService(IRelayProviderFactory factory, var provider = factory.BuildFromSettings(request.Provider, request.Settings); _runs[run.RunId] = run; - // Fire and forget — caller gets the runId immediately + // Fire and forget - caller gets the runId immediately _ = Task.Run(() => ExecuteAsync(run, provider, request.TestRecipient, ct)); return run.RunId; @@ -1905,7 +1926,7 @@ public class ProviderTestService(IRelayProviderFactory factory, } ``` -**Completed test runs** are held in memory for 30 minutes then evicted (a `Timer`-based cleanup pass). They are never persisted to SQL Server — test runs are ephemeral UI state only. +**Completed test runs** are held in memory for 30 minutes then evicted (a `Timer`-based cleanup pass). They are never persisted to SQL Server - test runs are ephemeral UI state only. ### 11.6 Per-Provider Log Detail @@ -1913,8 +1934,8 @@ Each `IRelayProvider.SendAsync()` implementation populates `RelayResult.Provider | Provider | Detail logged on success | Detail logged on failure | |---|---|---| -| **SendGrid** | `HTTP 202 Accepted — X-Message-Id: {id}` | HTTP status + full response body | -| **Mailgun** | `HTTP 200 — id: {mailgun-id}, message: Queued` | HTTP status + `message` field from JSON response | +| **SendGrid** | `HTTP 202 Accepted - X-Message-Id: {id}` | HTTP status + full response body | +| **Mailgun** | `HTTP 200 - id: {mailgun-id}, message: Queued` | HTTP status + `message` field from JSON response | | **Azure Comm.** | `OperationId: {id}, Status: Succeeded` | `ErrorCode`, `ErrorMessage` from SDK exception | | **SMTP** | `250 {server response line}` | SMTP error code + message, TLS negotiation detail if TLS failed | | **None** | `Discarded (dev mode)` | n/a | @@ -1940,17 +1961,17 @@ The test deliberately exercises the full code path a live message would take, so **`appsettings.json` contains exactly two things: the database connection string and the web UI TLS certificate path.** -The TLS cert path is the one exception to the "everything in SQL" rule — ASP.NET Core needs it to start the HTTPS listener before the SQL connection is established, so it cannot live in the database. Everything else is in SQL. +The TLS cert path is the one exception to the "everything in SQL" rule - ASP.NET Core needs it to start the HTTPS listener before the SQL connection is established, so it cannot live in the database. Everything else is in SQL. -Everything else — listener ports, SMTP auth credentials, spool directory, worker count, retry policy, provider selection, API keys, purge settings, web UI port and password — is stored in a `config` table in SQL Server and managed through the web UI. This means: +Everything else - listener ports, SMTP auth credentials, spool directory, worker count, retry policy, provider selection, API keys, purge settings, web UI port and password - is stored in a `config` table in SQL Server and managed through the web UI. This means: -- Minimal config file — only two fields, both written by the bootstrap installer -- Settings changes from the web UI are live immediately — no file reload, no restart +- Minimal config file - only two fields, both written by the bootstrap installer +- Settings changes from the web UI are live immediately - no file reload, no restart - All sensitive values (API keys, passwords) are encrypted at rest in SQL with a machine-specific key -- Upgrading never has a "config file migration" problem — SQL schema migrations handle it +- Upgrading never has a "config file migration" problem - SQL schema migrations handle it - The service cannot start without SQL, but once running, all configuration is authoritative from the database -### 12.2 `appsettings.json` — Connection String and TLS Cert +### 12.2 `appsettings.json` - Connection String and TLS Cert ```json { @@ -1964,7 +1985,7 @@ Everything else — listener ports, SMTP auth credentials, spool directory, work } ``` -The `WebUi.TlsCertPath` and `WebUi.TlsCertPassword` fields are stored in `appsettings.json` — not in the SQL `config` table — because ASP.NET Core needs them to start the HTTPS listener before SQL is reachable. The cert password is encrypted using the same machine-specific key as all other secrets. All other settings remain in SQL. +The `WebUi.TlsCertPath` and `WebUi.TlsCertPassword` fields are stored in `appsettings.json` - not in the SQL `config` table - because ASP.NET Core needs them to start the HTTPS listener before SQL is reachable. The cert password is encrypted using the same machine-specific key as all other secrets. All other settings remain in SQL. **File locations:** @@ -1986,7 +2007,7 @@ CREATE TABLE config ( ); ``` -Values marked `encrypted = 1` are stored AES-256 encrypted using a machine-specific key (DPAPI on Windows; PBKDF2 from `/etc/machine-id` on Linux). The encryption/decryption is transparent — the `ConfigRepository` handles it on read/write. +Values marked `encrypted = 1` are stored AES-256-GCM encrypted using a random 256-bit key persisted in a portable `.dispatch-key` file (access-restricted: mode 600 on Unix; ACL-locked on Windows, in the ProgramData data dir whose folder ACL the installer also restricts). The key is portable, so a DB backup restores on a different machine when the key file is restored too. If no writable key directory is available it falls back to a PBKDF2 machine-derived key (weaker). Earlier Windows builds used DPAPI (LocalMachine); those legacy values are still readable and migrate to AES on the next save. The encryption/decryption is transparent - the `ConfigRepository` handles it on read/write. **Config keys:** @@ -2019,13 +2040,13 @@ Values marked `encrypted = 1` are stored AES-256 encrypted using a machine-speci > Relay credentials use the key prefix `relay:{id}:` where `{id}` is the relay's integer primary key from the `relays` table. This allows independent credentials per named relay. | `api.enabled` | bool | `true` | No | -| `api.port` | int | `8081` | No | +| `api.port` | int | `8025` | No | | `api.allowed_cidrs` | JSON array | `["127.0.0.1/32"]` | No | | `api.tls_cert_path` | string | `` | No | | `api.tls_cert_password` | string | `` | **Yes** | | `api.max_message_bytes` | int | `0` (no limit) | No | | `api.rate_limit_per_key` | int | `100` | No | -| `webui.port` | int | `8080` | No | +| `webui.port` | int | `8420` | No | | `webui.allowed_cidrs` | JSON array | `["127.0.0.1/32"]` | No | | `webui.tls_cert_path` | string | `` | No | | `webui.tls_cert_password` | string | `` | **Yes** | @@ -2061,7 +2082,7 @@ CREATE TABLE config_smtp_credentials ( ### 12.5 Reading Config at Runtime -At startup Dispatch loads the full config table into a `ConfigCache` in memory — one SQL query, all keys. This cache is the source of truth for `IRelayProvider`, `SpoolWorkerPool`, `PurgeWorker`, and all other consumers: +At startup Dispatch loads the full config table into a `ConfigCache` in memory - one SQL query, all keys. This cache is the source of truth for `IRelayProvider`, `SpoolWorkerPool`, `PurgeWorker`, and all other consumers: ```csharp public class ConfigCache(IConfigRepository repo) @@ -2083,19 +2104,19 @@ When the web UI saves a setting via `PUT /api/config/{section}`, the endpoint: 2. Calls `ConfigCache.LoadAsync()` to refresh the in-memory cache 3. Returns `200 OK` -Workers and services always read from `ConfigCache` — never from `IOptionsMonitor` or `appsettings.json`. There is no file watcher. Settings are live as soon as the cache refreshes, which happens within the same HTTP request that saved them. +Workers and services always read from `ConfigCache` - never from `IOptionsMonitor` or `appsettings.json`. There is no file watcher. Settings are live as soon as the cache refreshes, which happens within the same HTTP request that saved them. ### 12.6 Config Bootstrap (First Run) -When Dispatch starts and the `config` table is empty (first run after schema migration), it populates all keys with their default values. This means a freshly installed instance is immediately usable with sensible defaults — the administrator only needs to configure the relay provider. +When Dispatch starts and the `config` table is empty (first run after schema migration), it populates all keys with their default values. This means a freshly installed instance is immediately usable with sensible defaults - the administrator only needs to configure the relay provider. ### 12.7 Config and SQL Outage -The `ConfigCache` is loaded at startup. If SQL goes down after startup, the in-memory cache remains valid and all services continue with the last-known settings. Config writes (from the web UI) fail with a visible error while SQL is down — the user is told to try again once SQL recovers. Mail flow is unaffected. +The `ConfigCache` is loaded at startup. If SQL goes down after startup, the in-memory cache remains valid and all services continue with the last-known settings. Config writes (from the web UI) fail with a visible error while SQL is down - the user is told to try again once SQL recovers. Mail flow is unaffected. -### 12.8 Startup Failure — SQL Unavailable +### 12.8 Startup Failure - SQL Unavailable -If SQL Server cannot be reached at startup, Dispatch cannot load its config and cannot start. It logs a clear error to the file sink and exits with a non-zero code. The systemd unit (Linux) and Windows Service manager will retry on the configured restart interval. This is the correct behaviour — without config, there is nothing safe to do. +If SQL Server cannot be reached at startup, Dispatch cannot load its config and cannot start. It logs a clear error to the file sink and exits with a non-zero code. The systemd unit (Linux) and Windows Service manager will retry on the configured restart interval. This is the correct behaviour - without config, there is nothing safe to do. The connection string in `appsettings.json` is the only dependency Dispatch has at startup. If that file is missing or the connection string is wrong, the bootstrap should be re-run. @@ -2103,18 +2124,18 @@ The connection string in `appsettings.json` is the only dependency Dispatch has ## 13. Logging -### 13.1 Log Storage — Database + File +### 13.1 Log Storage - Database + File Dispatch stores relay event logs in **two places** with different purposes: | Store | What | Purpose | |---|---|---| -| `relay_log` (SQL Server) | One row per relay lifecycle event | Web UI Message Log — searchable, filterable, auto-purged | -| File (rolling daily) | Full Serilog structured output | Diagnostics, crash analysis, support — kept on disk | +| `relay_log` (SQL Server) | One row per relay lifecycle event | Web UI Message Log - searchable, filterable, auto-purged | +| File (rolling daily) | Full Serilog structured output | Diagnostics, crash analysis, support - kept on disk | | Console | Full Serilog output | Foreground / Docker / journalctl live tail | | InMemoryRingBuffer | Last 1,000 events | Real-time SignalR push to the Dashboard | -The `relay_log` table is the primary data source for the web UI Message Log. The file sink captures everything — including internal errors, startup events, and provider library output — for deeper diagnostics. The two stores have independent retention policies. +The `relay_log` table is the primary data source for the web UI Message Log. The file sink captures everything - including internal errors, startup events, and provider library output - for deeper diagnostics. The two stores have independent retention policies. ### 13.2 Serilog Sinks @@ -2125,7 +2146,7 @@ The `relay_log` table is the primary data source for the web UI Message Log. The | `RelayLogDbSink` (custom) | In `Dispatch.Core` | Writes relay events to `relay_log`; ignores internal diagnostic events | | `RingBufferSink` (custom) | In `Dispatch.Core` | In-memory ring, feeds SignalR hub | -The `RelayLogDbSink` filters on the `SourceContext` property — it only writes events emitted by `RelayWorker`, `MessageStore`, and `ProviderTestService`. Internal .NET / ASP.NET Core / SmtpServer library events are written to the file sink only, keeping `relay_log` clean and purpose-focused. +The `RelayLogDbSink` filters on the `SourceContext` property - it only writes events emitted by `RelayWorker`, `MessageStore`, and `ProviderTestService`. Internal .NET / ASP.NET Core / SmtpServer library events are written to the file sink only, keeping `relay_log` clean and purpose-focused. ```csharp Log.Logger = new LoggerConfiguration() @@ -2164,7 +2185,7 @@ Every relay lifecycle event written to `relay_log` carries these properties: | ProviderMessageId | `<20260611.abc123@mailgun.net>` | | DurationMs | 312 | | RetryAttempt | 0 | -| ProviderResponse | HTTP 200 — id: `<...>`, message: Queued | +| ProviderResponse | HTTP 200 - id: `<...>`, message: Queued | | Error | *(provider error message if failed)* | | IngestSource | SMTP / API | | SourceIp | 192.168.1.45 | @@ -2191,7 +2212,7 @@ Log rows in `relay_log` are auto-purged by the `PurgeWorker`. Spool files in `sp ### 14.1 Spool Disk Growth and Back-Pressure -The spool directory grows when the provider is slow or unavailable — messages arrive faster than they are dispatched. Left unchecked this could fill the disk and cause `250 OK` to fail (the one failure mode that loses a message). +The spool directory grows when the provider is slow or unavailable - messages arrive faster than they are dispatched. Left unchecked this could fill the disk and cause `250 OK` to fail (the one failure mode that loses a message). **Disk monitoring:** @@ -2200,23 +2221,23 @@ The `PurgeWorker` checks free disk space on every run and on startup. If free sp | Free disk remaining | Action | |---|---| | < 1 GB | 🟡 Warning logged and shown in web UI dashboard | -| < 500 MB | 🟠 `smtp-accept` flag set to **throttled** — SMTP listener delays `250 OK` by 2 s per message to slow inbound rate | -| < 200 MB | 🔴 `smtp-accept` flag set to **suspended** — listener returns `452 Insufficient system storage` to senders (RFC 5321 compliant temporary rejection; senders will retry) | +| < 500 MB | 🟠 `smtp-accept` flag set to **throttled** - SMTP listener delays `250 OK` by 2 s per message to slow inbound rate | +| < 200 MB | 🔴 `smtp-accept` flag set to **suspended** - listener returns `452 Insufficient system storage` to senders (RFC 5321 compliant temporary rejection; senders will retry) | | Free space recovers > 500 MB | Flag automatically cleared; normal operation resumes | -The `452` response is the correct SMTP signal — well-behaved senders (all standard SMTP clients) will queue and retry. No messages are lost. +The `452` response is the correct SMTP signal - well-behaved senders (all standard SMTP clients) will queue and retry. No messages are lost. **`FileSystemWatcher` reliability:** -The OS can drop `FileSystemWatcher` events under very high load (this is a known .NET limitation on all platforms). Dispatch defends against this with a fallback sweep: if no `FileSystemWatcher` event has fired for more than 5 seconds and workers are idle, each worker falls back to `Directory.EnumerateFiles(incoming)` directly. This poll interval is not latency-sensitive — it only activates when events are missed under heavy load. +The OS can drop `FileSystemWatcher` events under very high load (this is a known .NET limitation on all platforms). Dispatch defends against this with a fallback sweep: if no `FileSystemWatcher` event has fired for more than 5 seconds and workers are idle, each worker falls back to `Directory.EnumerateFiles(incoming)` directly. This poll interval is not latency-sensitive - it only activates when events are missed under heavy load. ```csharp -// In each worker loop — fallback sweep when doorbell is quiet +// In each worker loop - fallback sweep when doorbell is quiet var signal = await _spool.WaitAsync(ct) .AsTask() .WaitAsync(TimeSpan.FromSeconds(5), ct) .ConfigureAwait(false); -// Whether we got a signal or timed out — always check for files +// Whether we got a signal or timed out - always check for files var eml = ClaimNextFile(); ``` @@ -2226,19 +2247,19 @@ Size enforcement operates at two layers: a **global ceiling** checked before rou #### Why two layers -The SMTP `SIZE` extension in the `EHLO` response must be a single static value — it is advertised before any envelope information is known, so Dispatch cannot know at that point which relay will handle the message. The global ceiling is the only safe value to advertise. +The SMTP `SIZE` extension in the `EHLO` response must be a single static value - it is advertised before any envelope information is known, so Dispatch cannot know at that point which relay will handle the message. The global ceiling is the only safe value to advertise. -Once `MAIL FROM` and `RCPT TO` have been received, the routing engine can be run and the exact relay is known. At that point the per-relay limit is enforced — before `DATA` is transmitted, so the sender never uploads a payload that will be rejected. +Once `MAIL FROM` and `RCPT TO` have been received, the routing engine can be run and the exact relay is known. At that point the per-relay limit is enforced - before `DATA` is transmitted, so the sender never uploads a payload that will be rejected. #### Global Ceiling (`listener.max_message_bytes`) -Advertised in the SMTP `EHLO` response as `SIZE={n}`. This is a hard upper bound — no message larger than this will ever be accepted regardless of which relay handles it. Default: `0` (no global limit). Set to a value in bytes to enforce a ceiling, e.g. `26214400` for 25 MB. +Advertised in the SMTP `EHLO` response as `SIZE={n}`. This is a hard upper bound - no message larger than this will ever be accepted regardless of which relay handles it. Default: `0` (no global limit). Set to a value in bytes to enforce a ceiling, e.g. `26214400` for 25 MB. The sender reads this value and will not attempt to send a message larger than advertised. This is the primary guard against large uploads. #### Per-Relay Limit (`relays.max_message_bytes`) -Each named relay has a `max_message_bytes` field. The default is `0` — no limit. Administrators can set a limit in bytes on any relay if they want Dispatch to enforce it before dispatch (e.g. set 10485760 on an Azure relay to reject oversized messages early rather than letting the provider reject them). The table below shows the upstream provider limits as a reference for choosing sensible values: +Each named relay has a `max_message_bytes` field. The default is `0` - no limit. Administrators can set a limit in bytes on any relay if they want Dispatch to enforce it before dispatch (e.g. set 10485760 on an Azure relay to reject oversized messages early rather than letting the provider reject them). The table below shows the upstream provider limits as a reference for choosing sensible values: | Provider type | Upstream provider limit (reference only) | |---|---| @@ -2273,8 +2294,8 @@ RCPT TO: relay.max_message_bytes > 0? → yes: check declared SIZE <= relay limit if over: 552 5.3.4 Message too large for relay "Azure-Comms" - (no DATA phase — sender never uploads the payload) - → no (= 0): pass through — relay has no size limit configured + (no DATA phase - sender never uploads the payload) + → no (= 0): pass through - relay has no size limit configured │ DATA → message bytes written to spool/incoming/ │ @@ -2287,7 +2308,7 @@ The third check (actual vs declared) catches senders that lie in their `SIZE=` d #### SmtpServer Filter Implementation -SmtpServer's `IMailboxFilter.CanDeliverToAsync` is called once per `RCPT TO` recipient — this is the correct hook for per-relay size enforcement because both `MAIL FROM` (with `SIZE=`) and the first recipient are available: +SmtpServer's `IMailboxFilter.CanDeliverToAsync` is called once per `RCPT TO` recipient - this is the correct hook for per-relay size enforcement because both `MAIL FROM` (with `SIZE=`) and the first recipient are available: ```csharp public class SizeCheckMailboxFilter( @@ -2330,7 +2351,7 @@ public class SizeCheckMailboxFilter( var declaredSize = ctx.Properties.TryGetValue("DeclaredSize", out var s) ? (int)s : 0; - if (declaredSize == 0) return MailboxFilterResult.Yes; // no SIZE declared — check after DATA + if (declaredSize == 0) return MailboxFilterResult.Yes; // no SIZE declared - check after DATA var relay = await routing.ResolveAsync( from.AsAddress(), @@ -2407,13 +2428,13 @@ public async Task HandleAsync(SendMessageRequest req, CancellationToken #### SIZE Advertisement -The `SIZE` value advertised in `EHLO` is always `listener.max_message_bytes` — the global ceiling. It is **not** the minimum across all relay limits, because: +The `SIZE` value advertised in `EHLO` is always `listener.max_message_bytes` - the global ceiling. It is **not** the minimum across all relay limits, because: - The per-relay limit is only knowable after `RCPT TO` - Advertising the minimum would unfairly cap senders for messages that will route to a high-limit relay -- The sender's SMTP library uses the advertised `SIZE` to decide whether to even attempt sending — advertising too low causes unnecessary rejections before Dispatch has enough information to route correctly +- The sender's SMTP library uses the advertised `SIZE` to decide whether to even attempt sending - advertising too low causes unnecessary rejections before Dispatch has enough information to route correctly -With the default of `0` (no global limit), the `SIZE` extension is not advertised in `EHLO` at all — senders see no size restriction from Dispatch's side. Set `listener.max_message_bytes` only if you want a hard ceiling across all relays regardless of routing, e.g. to prevent very large messages entering the spool at all. +With the default of `0` (no global limit), the `SIZE` extension is not advertised in `EHLO` at all - senders see no size restriction from Dispatch's side. Set `listener.max_message_bytes` only if you want a hard ceiling across all relays regardless of routing, e.g. to prevent very large messages entering the spool at all. ### 14.3 Provider Hot-Swap Behaviour @@ -2454,20 +2475,20 @@ GET /health } ``` -**Response (degraded — SQL down, mail still flowing):** +**Response (degraded - SQL down, mail still flowing):** ```json { "status": "degraded", - "message": "SQL Server unavailable — mail flow unaffected; UI log unavailable", + "message": "SQL Server unavailable - mail flow unaffected; UI log unavailable", ... } ``` -**Response (critical — disk low, SMTP suspended):** +**Response (critical - disk low, SMTP suspended):** ```json { "status": "critical", - "message": "Disk space critically low — SMTP intake suspended", + "message": "Disk space critically low - SMTP intake suspended", ... } ``` @@ -2489,14 +2510,14 @@ On Windows, the spool directory under `C:\ProgramData\Dispatch\spool\` is create ## 15. Windows Installation -### 15.1 Overview — Two-Part Delivery +### 15.1 Overview - Two-Part Delivery Windows installation uses two separate artefacts that run in sequence: | Artefact | Role | |---|---| -| `DispatchSetup.exe` | **Bootstrap installer** — handles SQL Server detection/install and database setup, then hands off to the MSI | -| `Dispatch-{version}-x64.msi` | **Application installer** — installs binaries, registers the Windows Service, creates shortcuts | +| `DispatchSetup.exe` | **Bootstrap installer** - handles SQL Server detection/install and database setup, then hands off to the MSI | +| `Dispatch-{version}-x64.msi` | **Application installer** - installs binaries, registers the Windows Service, creates shortcuts | Users always run `DispatchSetup.exe`. The MSI is never run directly in normal usage. @@ -2504,7 +2525,7 @@ Users always run `DispatchSetup.exe`. The MSI is never run directly in normal us ### 15.2 Bootstrap Installer (`DispatchSetup.exe`) -The bootstrap is a standalone .NET 9 WinForms application (single-file, self-contained, no .NET prerequisite needed — compiled to native via NativeAOT or published as a single-file executable). It presents a simple wizard UI and handles all pre-flight setup before the MSI runs. +The bootstrap is a standalone .NET 10 WinForms application (single-file, self-contained, no .NET prerequisite needed - compiled to native via NativeAOT or published as a single-file executable). It presents a simple wizard UI and handles all pre-flight setup before the MSI runs. #### Bootstrap Wizard Flow @@ -2574,8 +2595,8 @@ The bootstrap is a standalone .NET 9 WinForms application (single-file, self-con On launch, the bootstrap silently attempts to connect to the following candidates in order: -1. `localhost\SQLEXPRESS` — default SQL Server Express named instance -2. `localhost` — default SQL Server instance +1. `localhost\SQLEXPRESS` - default SQL Server Express named instance +2. `localhost` - default SQL Server instance 3. Any instance advertised by SQL Server Browser on the local machine (via `SqlDataSourceEnumerator`) If a reachable instance is found, the wizard pre-selects **Connect to an existing SQL Server** and pre-fills the server name. The user can confirm or change it. @@ -2606,19 +2627,19 @@ SQLEXPR_x64_ENU.exe /Q /ACTION=Install /FEATURES=SQLEngine When the user chooses **Connect to an existing SQL Server**: -- **Server** — free-text field, accepts `hostname`, `hostname\instance`, `hostname,port` -- **Auth mode** — Windows Auth (default) or SQL Auth -- **Username / Password** — shown only when SQL Auth is selected -- **Test Connection** button — attempts `SELECT 1` and reports success or the exact error message +- **Server** - free-text field, accepts `hostname`, `hostname\instance`, `hostname,port` +- **Auth mode** - Windows Auth (default) or SQL Auth +- **Username / Password** - shown only when SQL Auth is selected +- **Test Connection** button - attempts `SELECT 1` and reports success or the exact error message - The user cannot proceed until Test Connection succeeds -The bootstrap needs `db_creator` rights on the target instance (to create the `DispatchQueue` database). After initial setup, Dispatch only needs `db_datawriter` + `db_datareader` on that database. The bootstrap creates a dedicated `dispatch` SQL login with minimal rights after database creation (when SQL Auth is used). +The bootstrap needs `db_creator` rights on the target instance (to create the `DispatchLog` database). After initial setup, Dispatch only needs `db_datawriter` + `db_datareader` on that database. The bootstrap creates a dedicated `dispatch` SQL login with minimal rights after database creation (when SQL Auth is used). #### Database Initialisation After a successful connection test: -1. `CREATE DATABASE DispatchQueue` (if it does not already exist) +1. `CREATE DATABASE DispatchLog` (if it does not already exist) 2. Runs embedded schema SQL scripts in order to create tables and indexes 3. If the database already exists and has the schema table, applies any pending migration scripts (idempotent) 4. Writes the final connection string (encrypted) to `C:\ProgramData\Dispatch\appsettings.json` @@ -2637,14 +2658,14 @@ The MSI finds the pre-written `appsettings.json` in `ProgramData` and does not t ### 15.3 MSI (`Dispatch-{version}-x64.msi`) -Authored with WiX Toolset v5. Targets x64 Windows 10 / Server 2019 and later. The MSI assumes SQL Server and the database are already configured (by the bootstrap) — it does not interact with SQL Server. +Authored with WiX Toolset v6. Targets x64 Windows 10 / Server 2019 and later. The MSI assumes SQL Server and the database are already configured (by the bootstrap) - it does not interact with SQL Server. **MSI Actions:** - Copy binaries to `C:\Program Files\Dispatch\` - Ensure `C:\ProgramData\Dispatch\` exists (preserves existing `appsettings.json`) - Register Dispatch as a Windows Service via `ServiceInstall` / `ServiceControl` elements -- Create Start Menu shortcut that opens `https://localhost:8080` +- Create Start Menu shortcut that opens `https://localhost:8420` - Create Windows Firewall rules for all required ports (see Section 11.3a) - Register Add/Remove Programs entry with version, publisher, and uninstall support @@ -2652,9 +2673,9 @@ Authored with WiX Toolset v5. Targets x64 Windows 10 / Server 2019 and later. Th The MSI creates inbound firewall rules for every port Dispatch listens on. Rules are created using the WiX `Firewall` extension (`WixToolset.Firewall.wixext`) and are automatically removed on uninstall. -**All rules use `Scope="any"`** — the Windows Firewall is a port-opener only. Access control (which source IPs are allowed) is enforced at the application layer via CIDR allow-lists configured in the web UI. This means: +**All rules use `Scope="any"`** - the Windows Firewall is a port-opener only. Access control (which source IPs are allowed) is enforced at the application layer via CIDR allow-lists configured in the web UI. This means: -- Denied connections reach the application and are **logged** with the source IP and reason — visible in the web UI +- Denied connections reach the application and are **logged** with the source IP and reason - visible in the web UI - Allow-lists can be changed at runtime in the web UI without touching firewall rules or restarting anything - The model is consistent on Windows and Linux (Linux has no equivalent to WiX firewall rules) @@ -2665,8 +2686,8 @@ The MSI creates inbound firewall rules for every port Dispatch listens on. Rules | `Dispatch SMTP` | Inbound | TCP | 25 | Any | App-layer CIDR (`listener.allowed_cidrs`) | | `Dispatch SMTP Submission` | Inbound | TCP | 587 | Any | App-layer CIDR (`listener.allowed_cidrs`) | | `Dispatch SMTPS` | Inbound | TCP | 465 | Any | App-layer CIDR (`listener.allowed_cidrs`) | -| `Dispatch Admin UI` | Inbound | TCP | 8080 | Any | App-layer CIDR (`webui.allowed_cidrs`) | -| `Dispatch API` | Inbound | TCP | 8081 | Any | App-layer CIDR (`api.allowed_cidrs`) | +| `Dispatch Admin UI` | Inbound | TCP | 8420 | Any | App-layer CIDR (`webui.allowed_cidrs`) | +| `Dispatch API` | Inbound | TCP | 8025 | Any | App-layer CIDR (`api.allowed_cidrs`) | **WiX source (`firewall.wxs`):** @@ -2702,15 +2723,15 @@ The MSI creates inbound firewall rules for every port Dispatch listens on. Rules + Description="Dispatch web administration dashboard (port 8420)" + Port="8420" Protocol="tcp" Scope="any" Profile="all" /> + Description="Dispatch HTTP ingestion API (port 8025)" + Port="8025" Protocol="tcp" Scope="any" Profile="all" /> @@ -2725,10 +2746,10 @@ The MSI has no access to the SQL config table at install time. To set non-defaul |---|---|---| | `SMTP_PORT` | `25` | Port for the `Dispatch SMTP` firewall rule | | `SUBMISSION_PORT` | `587` | Port for the `Dispatch SMTP Submission` rule | -| `ADMINUI_PORT` | `8080` | Port for the `Dispatch Admin UI` rule | -| `API_PORT` | `8081` | Port for the `Dispatch API` rule | +| `ADMINUI_PORT` | `8420` | Port for the `Dispatch Admin UI` rule | +| `API_PORT` | `8025` | Port for the `Dispatch API` rule | -All firewall rules use `Scope="any"` — access control is managed at the application layer. Scope is not configurable at MSI install time. +All firewall rules use `Scope="any"` - access control is managed at the application layer. Scope is not configurable at MSI install time. Example silent install with custom ports: @@ -2742,7 +2763,7 @@ msiexec /i Dispatch-1.0.0-x64.msi /quiet /norestart ^ **Upgrade behaviour:** -On upgrade the WiX `MajorUpgrade` sequence removes old firewall rules and recreates them. Custom port properties must be passed again on upgrade — the MSI has no access to the SQL config table. +On upgrade the WiX `MajorUpgrade` sequence removes old firewall rules and recreates them. Custom port properties must be passed again on upgrade - the MSI has no access to the SQL config table. **Rule visibility:** @@ -2786,7 +2807,7 @@ Bootstrap detects existing install │ ▼ Run database schema migration - (apply any new SQL scripts — idempotent) + (apply any new SQL scripts - idempotent) │ ▼ Launch MSI upgrade @@ -2805,7 +2826,7 @@ Bootstrap detects existing install #### 11.4.2 Queue Drain Before Upgrade -Before stopping the service, the bootstrap calls `POST /api/service/drain` (or reads the queue table directly if the service is not responding). It waits up to 60 seconds for all `Processing` rows to settle to `Delivered` or `Failed`. Any rows still stuck in `Processing` after the timeout are reset to `Pending` so they will be retried when the upgraded service starts. `Pending` rows are untouched — they queue naturally after restart. +Before stopping the service, the bootstrap calls `POST /api/service/drain` (or reads the queue table directly if the service is not responding). It waits up to 60 seconds for all `Processing` rows to settle to `Delivered` or `Failed`. Any rows still stuck in `Processing` after the timeout are reset to `Pending` so they will be retried when the upgraded service starts. `Pending` rows are untouched - they queue naturally after restart. #### 11.4.3 Database Schema Migration @@ -2838,13 +2859,13 @@ Each migration script is wrapped in a transaction with a `ROLLBACK` on error, so | Item | Upgrade behaviour | |---|---| -| `appsettings.json` | **Preserved** — contains only the connection string; MSI never overwrites it | -| Encryption keys / secrets | **Preserved** — machine-specific key derived from OS; no key material stored in files | -| Queue data in SQL Server | **Preserved** — only additive schema changes; no destructive migrations | -| Delivered / failed message history | **Preserved** — purge policy unchanged | -| Firewall rules | **Recreated** — old rules removed, new rules added by the new MSI (same ports unless overridden) | -| Windows Service registration | **Preserved** — `ServiceInstall` uses the same service name; WiX handles the update | -| Log files | **Preserved** — log directory in `ProgramData` is never touched by the MSI | +| `appsettings.json` | **Preserved** - contains only the connection string; MSI never overwrites it | +| Encryption keys / secrets | **Preserved** - machine-specific key derived from OS; no key material stored in files | +| Queue data in SQL Server | **Preserved** - only additive schema changes; no destructive migrations | +| Delivered / failed message history | **Preserved** - purge policy unchanged | +| Firewall rules | **Recreated** - old rules removed, new rules added by the new MSI (same ports unless overridden) | +| Windows Service registration | **Preserved** - `ServiceInstall` uses the same service name; WiX handles the update | +| Log files | **Preserved** - log directory in `ProgramData` is never touched by the MSI | #### 11.4.5 Rollback @@ -2856,14 +2877,14 @@ Database schema migrations are **not** automatically rolled back (SQL Server DDL 2. The service is not started 3. The user is shown the error with instructions to report it as a bug 4. The previous binaries are restored by Windows Installer rollback -5. The user can manually start the old service version — it will work because the partially-applied schema is backwards-compatible by design (all migrations are additive only — no column drops, no renames) +5. The user can manually start the old service version - it will work because the partially-applied schema is backwards-compatible by design (all migrations are additive only - no column drops, no renames) -**Migration design rule enforced in code review:** every migration script must be additive only. Breaking schema changes (column renames, type changes, drops) must be phased across two releases — add the new structure in release N, remove the old in release N+1 after confirming no old binary reads it. +**Migration design rule enforced in code review:** every migration script must be additive only. Breaking schema changes (column renames, type changes, drops) must be phased across two releases - add the new structure in release N, remove the old in release N+1 after confirming no old binary reads it. #### 11.4.6 Silent Upgrade ```bat -:: Upgrade non-interactively — reads SQL Server connection from appsettings.json +:: Upgrade non-interactively - reads SQL Server connection from appsettings.json DispatchSetup-1.1.0-x64.exe --silent --upgrade :: Upgrade with explicit connection (overrides stored connection string) @@ -2877,10 +2898,10 @@ The `--upgrade` flag tells the bootstrap to skip the SQL Server setup wizard and For environments where SQL Server is already present, the full install can be scripted: ```bat -:: Step 1 — run bootstrap in unattended mode (skips wizard, uses existing SQL Server) +:: Step 1 - run bootstrap in unattended mode (skips wizard, uses existing SQL Server) DispatchSetup.exe --silent --server "DBSERVER\SQLEXPRESS" --auth windows -:: Step 2 — the bootstrap launches the MSI automatically when done +:: Step 2 - the bootstrap launches the MSI automatically when done :: Or launch the MSI directly after a manual bootstrap run: msiexec /i Dispatch-1.0.0-x64.msi /quiet /norestart ``` @@ -2890,7 +2911,7 @@ The bootstrap accepts the following CLI flags for unattended mode: | Flag | Description | |---|---| | `--silent` | Skip wizard UI; exit with code 0 on success, non-zero on failure | -| `--upgrade` | Explicit upgrade mode — skip SQL Server wizard, drain queue, migrate, install MSI | +| `--upgrade` | Explicit upgrade mode - skip SQL Server wizard, drain queue, migrate, install MSI | | `--server ` | SQL Server instance to connect to | | `--auth windows` | Use Windows Authentication (default) | | `--auth sql --user --password

` | Use SQL Authentication | @@ -2898,20 +2919,20 @@ The bootstrap accepts the following CLI flags for unattended mode: | `--no-launch-msi` | Run database setup only; do not launch the MSI | | `--smtp-port ` | Override SMTP firewall rule port passed to MSI (default 25) | | `--submission-port ` | Override submission firewall rule port passed to MSI (default 587) | -| `--adminui-port ` | Override Admin UI firewall rule port passed to MSI (default 8080) | -| `--api-port ` | Override API firewall rule port passed to MSI (default 8081) | +| `--adminui-port ` | Override Admin UI firewall rule port passed to MSI (default 8420) | +| `--api-port ` | Override API firewall rule port passed to MSI (default 8025) | --- ## 16. Linux Installation -### 16.1 Overview — Two-Part Delivery +### 16.1 Overview - Two-Part Delivery Linux installation mirrors the Windows approach: a bootstrap script handles SQL Server detection/install and database setup, then installs the Dispatch service. | Artefact | Role | |---|---| -| `install.sh` | **Bootstrap script** — interactive wizard in the terminal; handles SQL Server and hands off to service install | +| `install.sh` | **Bootstrap script** - interactive wizard in the terminal; handles SQL Server and hands off to service install | | `Dispatch-{version}-linux-x64.tar.gz` | Application binary + default config; extracted by `install.sh` | ### 16.2 Bootstrap Script (`install.sh`) @@ -2936,9 +2957,9 @@ SQL Server Setup Choice [1]: _ ``` -**If option 1 — Install SQL Server Express:** +**If option 1 - Install SQL Server Express:** ``` -Downloading Microsoft SQL Server 2022 Express... +Downloading Microsoft SQL Server 2025 Express... Adding Microsoft package repository... Installing mssql-server... Running initial configuration (SA password required)... @@ -2948,7 +2969,7 @@ Starting SQL Server service... ✓ SQL Server installed and running. ``` -**If option 2 — Connect to existing:** +**If option 2 - Connect to existing:** ``` Server (e.g. myserver or myserver\instance): _ Authentication: @@ -2958,7 +2979,7 @@ Choice [2]: _ Username: _ Password: _ -Testing connection... ✓ Connected to SQL Server 2022 Express (14.0.3456) +Testing connection... ✓ Connected to SQL Server 2025 Express ``` **Then for both paths:** @@ -2985,7 +3006,7 @@ Installing Dispatch service... ============================================= Setup complete! - Dashboard: https://localhost:8080 + Dashboard: https://localhost:8420 (Accept the self-signed certificate warning in your browser, or run: sudo dispatch --trust-cert to add it to the system store) ============================================= @@ -3001,8 +3022,8 @@ The script checks for a running `sqlservr` process and attempts `sqlcmd -S local # Add Microsoft package repository curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor \ -o /usr/share/keyrings/microsoft-prod.gpg -curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list \ - -o /etc/apt/sources.list.d/mssql-server-2022.list +curl -fsSL https://packages.microsoft.com/config/ubuntu/24.04/mssql-server-2025.list \ + -o /etc/apt/sources.list.d/mssql-server-2025.list apt-get update apt-get install -y mssql-server @@ -3022,10 +3043,10 @@ The SA password is prompted interactively and validated for SQL Server complexit After a successful connection: -1. `sqlcmd` creates `DispatchQueue` if it does not exist +1. `sqlcmd` creates `DispatchLog` if it does not exist 2. Embedded schema SQL scripts are applied in order 3. A dedicated `dispatch` SQL login is created with `db_datawriter` + `db_datareader` rights only -4. The SQL Server connection string is written to `/etc/dispatch/appsettings.json` (connection string only — no other settings) +4. The SQL Server connection string is written to `/etc/dispatch/appsettings.json` (connection string only - no other settings) ### 16.3 systemd Unit @@ -3122,13 +3143,13 @@ Starting dispatch service... ✓ ============================================= Upgrade complete! v1.0.0 → v1.1.0 - Dashboard: https://localhost:8080 + Dashboard: https://localhost:8420 ============================================= ``` #### 12.6.2 Queue Drain -Before stopping the service, `install.sh` calls `curl -sk -X POST https://localhost:8080/api/service/drain` (`-k` skips cert verification for self-signed certs) and polls until the response confirms zero `Processing` rows, or 60 seconds elapse. Any rows still in `Processing` after the timeout are reset to `Pending` before the service is stopped. +Before stopping the service, `install.sh` calls `curl -sk -X POST https://localhost:8420/api/service/drain` (`-k` skips cert verification for self-signed certs) and polls until the response confirms zero `Processing` rows, or 60 seconds elapse. Any rows still in `Processing` after the timeout are reset to `Pending` before the service is stopped. #### 12.6.3 Database Schema Migration @@ -3138,12 +3159,12 @@ Identical migration logic to the Windows bootstrap (see Section 11.4.3). The `in | Item | Upgrade behaviour | |---|---| -| `/etc/dispatch/appsettings.json` | **Preserved** — contains only the connection string; `install.sh` never overwrites it | -| Encryption keys / secrets | **Preserved** — derived from `/etc/machine-id` at runtime; no key material stored | -| Queue data in SQL Server | **Preserved** — additive-only migrations | -| Log files in `/var/log/dispatch/` | **Preserved** — never touched by the upgrade | -| systemd unit file | **Replaced** — new unit file installed; `systemctl daemon-reload` run automatically | -| Binary at `/usr/local/bin/dispatch` | **Replaced** — old binary overwritten atomically | +| `/etc/dispatch/appsettings.json` | **Preserved** - contains only the connection string; `install.sh` never overwrites it | +| Encryption keys / secrets | **Preserved** - derived from `/etc/machine-id` at runtime; no key material stored | +| Queue data in SQL Server | **Preserved** - additive-only migrations | +| Log files in `/var/log/dispatch/` | **Preserved** - never touched by the upgrade | +| systemd unit file | **Replaced** - new unit file installed; `systemctl daemon-reload` run automatically | +| Binary at `/usr/local/bin/dispatch` | **Replaced** - old binary overwritten atomically | #### 12.6.5 Rollback @@ -3164,7 +3185,7 @@ sudo systemctl start dispatch #### 12.6.6 Unattended Upgrade ```bash -# Upgrade silently — reads SQL connection string from /etc/dispatch/appsettings.json +# Upgrade silently - reads SQL connection string from /etc/dispatch/appsettings.json curl -sSL https://github.com/yourorg/dispatch/releases/latest/download/install.sh | \ sudo bash -s -- --upgrade --silent @@ -3179,7 +3200,7 @@ curl -sSL https://github.com/yourorg/dispatch/releases/download/v1.1.0/install.s ### 17.1 Threat Model -Dispatch is an **internal SMTP relay** — it is not a public mail server and is never intended to be exposed to the internet. Senders are trusted internal applications, devices, and scripts on your network. The threat model is accordingly internal-focused. +Dispatch is an **internal SMTP relay** - it is not a public mail server and is never intended to be exposed to the internet. Senders are trusted internal applications, devices, and scripts on your network. The threat model is accordingly internal-focused. **Primary threats:** - Unauthorised access to the admin web UI (credential theft, weak session management) @@ -3191,23 +3212,23 @@ Dispatch is an **internal SMTP relay** — it is not a public mail server and is **Not in scope:** - Public internet attackers (network perimeter is assumed) - DDoS against the SMTP listener (mitigated at the network layer) -- Spam filtering (Dispatch relays what it receives — senders are trusted internal systems) +- Spam filtering (Dispatch relays what it receives - senders are trusted internal systems) - End-to-end message encryption (TLS in transit only) --- -### 17.2 TLS — Web UI (Mandatory) and HTTP API (Optional) +### 17.2 TLS - Web UI (Mandatory) and HTTP API (Optional) -#### Admin Web UI — HTTPS Only +#### Admin Web UI - HTTPS Only **The admin web UI is HTTPS-only. There is no plain HTTP option.** -The web UI exposes all credentials, provider API keys, routing rules, and API key management. Transmitting this over plain HTTP — even on an internal network — is unacceptable. A TLS certificate is a required prerequisite before the web UI will serve any content. +The web UI exposes all credentials, provider API keys, routing rules, and API key management. Transmitting this over plain HTTP - even on an internal network - is unacceptable. A TLS certificate is a required prerequisite before the web UI will serve any content. **Behaviour without a certificate configured:** - The web UI process starts and the SMTP listener works normally -- Any HTTP request to port 8080 returns a single page: a setup notice explaining that a TLS certificate must be configured, with instructions for generating a self-signed certificate or pointing to an existing one +- Any HTTP request to port 8420 returns a single page: a setup notice explaining that a TLS certificate must be configured, with instructions for generating a self-signed certificate or pointing to an existing one - No credentials, config, or log data are served over plain HTTP under any circumstance **Certificate configuration:** @@ -3222,12 +3243,12 @@ The web UI exposes all credentials, provider API keys, routing rules, and API ke Users will see a browser "Your connection is not private" warning when first accessing the web UI with a self-signed cert. This is expected and documented in the setup notice. Instructions are provided for adding the cert to the OS trust store or replacing it with an internal CA cert. -#### HTTP API — HTTP and HTTPS +#### HTTP API - HTTP and HTTPS -The HTTP API (port 8081) supports both plain HTTP and HTTPS: +The HTTP API (port 8025) supports both plain HTTP and HTTPS: - **HTTP is supported** because many internal senders (legacy apps, printers, scanners, IoT devices, scripts) cannot do HTTPS -- **HTTPS is supported** for senders that can use it — configured via `api.tls_cert_path` (optional, same cert as web UI or separate) +- **HTTPS is supported** for senders that can use it - configured via `api.tls_cert_path` (optional, same cert as web UI or separate) - API key authentication applies equally to both transports - The web UI shows a **yellow advisory** (not a blocker) when the API CIDR is wider than `127.0.0.1/32` without a TLS cert configured: *"API keys may be transmitted in plaintext over HTTP. Configure TLS for the API if your senders support it."* @@ -3251,7 +3272,7 @@ When `webui.require_auth = true`: - Session lifetime: **8 hours** idle timeout (configurable via `webui.session_timeout_minutes`); re-login required after expiry - Logout: `POST /auth/logout` clears the cookie server-side - **Brute-force protection**: 10 failed login attempts within 5 minutes = 15-minute lockout for that source IP (same mechanism as SMTP AUTH, tracked in-memory) -- The `GET /health` endpoint is exempt from auth — always accessible +- The `GET /health` endpoint is exempt from auth - always accessible **Password requirements** (enforced at the web UI password change form): - Minimum 12 characters @@ -3266,7 +3287,7 @@ When `webui.require_auth = true`: **Key storage:** bcrypt hash with cost factor 12, stored in `api_keys.key_hash`. The full key is shown once on creation and never stored in plaintext. -**Key verification timing:** The verification path always performs a bcrypt compare — even when the `key_id` prefix is not found in the database — to prevent timing-based enumeration of valid key prefixes: +**Key verification timing:** The verification path always performs a bcrypt compare - even when the `key_id` prefix is not found in the database - to prevent timing-based enumeration of valid key prefixes: ```csharp public async Task VerifyAsync(string rawKey) @@ -3276,7 +3297,7 @@ public async Task VerifyAsync(string rawKey) if (key is null) { - // Always do a bcrypt compare even when not found — prevents timing oracle + // Always do a bcrypt compare even when not found - prevents timing oracle BCrypt.Net.BCrypt.Verify(rawKey, _dummyHash); return null; } @@ -3285,9 +3306,9 @@ public async Task VerifyAsync(string rawKey) } ``` -**Revocation cache invalidation:** API key objects are cached for 30 seconds to avoid bcrypt on every request. When a key is revoked via the UI, `DELETE /api/keys/{id}` calls `ApiKeyCache.Invalidate(keyId)` immediately, removing the entry from the cache before returning `200 OK`. There is no grace period — a revoked key is invalid from the moment the revoke request completes. +**Revocation cache invalidation:** API key objects are cached for 30 seconds to avoid bcrypt on every request. When a key is revoked via the UI, `DELETE /api/keys/{id}` calls `ApiKeyCache.Invalidate(keyId)` immediately, removing the entry from the cache before returning `200 OK`. There is no grace period - a revoked key is invalid from the moment the revoke request completes. -**Key scope:** all current API keys have send-only scope. Future versions may add read-only keys (log access) — the `api_keys` table has a `scope` column reserved for this (`NULL` = full send access). +**Key scope:** all current API keys have send-only scope. Future versions may add read-only keys (log access) - the `api_keys` table has a `scope` column reserved for this (`NULL` = full send access). --- @@ -3303,9 +3324,8 @@ public async Task VerifyAsync(string rawKey) | API keys | `api_keys.key_hash` | bcrypt, cost factor 12 | | SQL connection string | `appsettings.json` | OS file permissions only (see 17.6) | -**Encryption key derivation:** -- **Windows:** `System.Security.Cryptography.ProtectedData` (DPAPI, `LocalMachine` scope) — key is tied to the machine and service account; no key material is stored -- **Linux:** PBKDF2-SHA256 from `/etc/machine-id` + a fixed application salt (100,000 iterations) → 256-bit key → AES-256-GCM. The derived key is held in memory only; never written to disk. +**Encryption key (all platforms):** a random 256-bit key in a `.dispatch-key` file → AES-256-GCM. The key file is access-restricted (mode 600 on Unix; an inheritance-stripped ACL granting only SYSTEM, Administrators, and the service account on Windows). On Windows it lives in the ProgramData data dir (the content root), whose folder ACL the installer also locks down. Being a portable file (not a machine-bound store), it makes DB backup/restore portable across hosts - restore the key file alongside the database. If no writable key directory is available, it falls back to a PBKDF2-SHA256 key derived from `/etc/machine-id` + a fixed app salt (100,000 iterations) - weaker, surfaced to the operator. +- **Legacy (older Windows builds):** `System.Security.Cryptography.ProtectedData` (DPAPI, `LocalMachine` scope). Such values are still decrypted on Windows and re-encrypted to AES on the next save. **Secrets never appear in:** log files, HTTP responses, error messages, exception stack traces, or the Serilog structured output. `ConfigRepository` redacts encrypted values in `GET /api/config` responses. @@ -3343,7 +3363,7 @@ When `spool.directory` is changed via the web UI, the new path is validated befo ### 17.7 SQL Injection Prevention -All SQL in Dispatch uses **Dapper with parameterised queries**. Values from user input, config, or log data are always passed as `@parameter` — never string-concatenated into SQL. +All SQL in Dispatch uses **Dapper with parameterised queries**. Values from user input, config, or log data are always passed as `@parameter` - never string-concatenated into SQL. The dynamic filter query in `GET /api/messages` builds its `WHERE` clause by appending fixed strings based on which parameters are present (e.g. `AND status IN @statuses`), never by interpolating values. All values are passed in the Dapper parameter object. This is verified in `MessageLogQueryTests` which tests each filter combination with SQL-injection payloads. @@ -3355,7 +3375,7 @@ Inbound SMTP messages are stored as raw RFC 5322 bytes and parsed by MimeKit at HTTP API field inputs (`from`, `to`, `subject`, custom `h:` headers) are passed to MimeKit's setter methods which normalise and validate values. MimeKit rejects malformed addresses and strips embedded newlines from header values. -**Blocked `h:` header names** — the following headers submitted via the HTTP API `h:` prefix are silently stripped and not added to the message: +**Blocked `h:` header names** - the following headers submitted via the HTTP API `h:` prefix are silently stripped and not added to the message: ``` Received, DKIM-Signature, DomainKey-Signature, Authentication-Results, @@ -3368,7 +3388,7 @@ These are either routing headers that should not be forged or internal Dispatch ### 17.9 SSRF Prevention -The generic SMTP relay provider accepts an admin-configured hostname. To prevent Server-Side Request Forgery (where an admin — or a compromised config — points Dispatch at an internal service or cloud metadata endpoint): +The generic SMTP relay provider accepts an admin-configured hostname. To prevent Server-Side Request Forgery (where an admin - or a compromised config - points Dispatch at an internal service or cloud metadata endpoint): **Blocked hostname patterns (validated before saving and before connecting):** @@ -3390,7 +3410,7 @@ private static readonly string[] BlockedHostnames = ]; ``` -DNS resolution is performed at validation time and the resolved IP is checked against the blocked ranges — not just the hostname string — to prevent DNS rebinding attacks. +DNS resolution is performed at validation time and the resolved IP is checked against the blocked ranges - not just the hostname string - to prevent DNS rebinding attacks. This validation applies to `relay:{id}:smtp.host` only. Provider SDK endpoints (Mailgun, SendGrid, Azure) are hardcoded in the provider implementations and not configurable by admins. @@ -3400,11 +3420,11 @@ This validation applies to `relay:{id}:smtp.host` only. Provider SDK endpoints ( | Risk | Mitigation | |---|---| -| Open relay — any sender accepted | `listener.allowed_cidrs` defaults to `127.0.0.1/32`; all other sources denied and logged | +| Open relay - any sender accepted | `listener.allowed_cidrs` defaults to `127.0.0.1/32`; all other sources denied and logged | | `0.0.0.0/0` CIDR without AUTH | Warning shown in UI; accepted but flagged | | AUTH credential brute-force | 5 failures per source IP = 60 s lockout; tracked in-memory per IP | | AUTH credential enumeration | Constant-time comparison for credential checks | -| Relay amplification | Messages relayed once only — no forwarding loops possible by design (Dispatch is not an MX server) | +| Relay amplification | Messages relayed once only - no forwarding loops possible by design (Dispatch is not an MX server) | --- @@ -3441,7 +3461,7 @@ Log.Logger = new LoggerConfiguration() .CreateLogger(); ``` -Provider error responses (stored in `relay_log.provider_response`) are stored verbatim — these sometimes contain partial account identifiers from the provider. This is intentional for diagnostics but documented as a sensitivity consideration. Administrators should be aware that `relay_log` may contain partial provider account data. +Provider error responses (stored in `relay_log.provider_response`) are stored verbatim - these sometimes contain partial account identifiers from the provider. This is intentional for diagnostics but documented as a sensitivity consideration. Administrators should be aware that `relay_log` may contain partial provider account data. --- @@ -3480,18 +3500,18 @@ GitHub Actions handles all CI/CD automation. | Publish Windows x64 service | `dotnet publish -r win-x64 --self-contained` | | Publish Windows bootstrap EXE | `dotnet publish Dispatch.Bootstrap.Windows -r win-x64 --self-contained` | | Publish Linux x64 | `dotnet publish -r linux-x64 --self-contained` | -| Build MSI | `dotnet build installer/windows` (WiX v5 MSBuild integration) | +| Build MSI | `dotnet build installer/windows` (WiX v6 MSBuild integration) | | Bundle bootstrap + MSI → Setup.exe | WiX Burn bootstrapper chain | | Build Linux tarball | `tar` packaging of binary + `install.sh` | | GitHub Release | Triggered on version tag push (`v*`) | ### 18.2 Release Artefacts -- `DispatchSetup-{version}-x64.exe` — **Windows bootstrap installer** (run this; bundles the MSI internally) -- `Dispatch-{version}-x64.msi` — Windows MSI (for IT admins doing scripted/silent deploys after manual DB setup) -- `Dispatch-{version}-linux-x64.tar.gz` — Linux self-contained archive (binary + `install.sh`) -- `install.sh` — Linux bootstrap script (also available standalone for `curl | bash` installs) -- `SHA256SUMS` — checksums for all release files +- `DispatchSetup-{version}-x64.exe` - **Windows bootstrap installer** (run this; bundles the MSI internally) +- `Dispatch-{version}-x64.msi` - Windows MSI (for IT admins doing scripted/silent deploys after manual DB setup) +- `Dispatch-{version}-linux-x64.tar.gz` - Linux self-contained archive (binary + `install.sh`) +- `install.sh` - Linux bootstrap script (also available standalone for `curl | bash` installs) +- `SHA256SUMS` - checksums for all release files --- @@ -3501,14 +3521,14 @@ GitHub Actions handles all CI/CD automation. Build projects in this order to avoid circular dependency issues: -1. **Dispatch.Core** — `SpoolDirectory`, `SpoolMessageStore`, `SpoolMeta`, `SpoolWorkerPool`, `RelaySemaphorePool`, `IRelayProvider`, `IRelayProviderFactory`, `RoutingEngine`, `IRoutingRuleRepository`, `IRelayRepository`, `ILogRepository`, `ICounterRepository`, `MinuteCounterRing`, `IConfigRepository`, `IApiKeyRepository`, `ConfigCache`, `RelayLogEntry`, in-memory ring buffer -2. **Dispatch.Providers** — `NoneProvider`, `SmtpProvider` (MailKit), `SendGridProvider`, `MailgunProvider`, `AzureProvider` -3. **Dispatch.Web** — ASP.NET Core host (port 8080 web UI + port 8081 API on separate listener), REST endpoints, `ApiMessageHandler`, `ApiKeyMiddleware`, SignalR hub, `EmbeddedFileProvider`, `LogRepository`, `ConfigRepository`, `ApiKeyRepository` -4. **Dispatch.UI** — React SPA; wire Vite build into Dispatch.Web via MSBuild `EmbeddedResource` -5. **Dispatch.Service** — `Program.cs`; wire `SpoolWorkerPool` + `PurgeWorker` as `IHostedService` -6. **Dispatch.Bootstrap.Windows** — WinForms setup wizard (SQL detect/install, schema migration, MSI launch) -7. **installer/windows** — WiX v5 `.wxs` MSI; WiX Burn chain wrapping `DispatchSetup.exe` -8. **installer/linux** — systemd unit + `install.sh` +1. **Dispatch.Core** - `SpoolDirectory`, `SpoolMessageStore`, `SpoolMeta`, `SpoolWorkerPool`, `RelaySemaphorePool`, `IRelayProvider`, `IRelayProviderFactory`, `RoutingEngine`, `IRoutingRuleRepository`, `IRelayRepository`, `ILogRepository`, `ICounterRepository`, `MinuteCounterRing`, `IConfigRepository`, `IApiKeyRepository`, `ConfigCache`, `RelayLogEntry`, in-memory ring buffer +2. **Dispatch.Providers** - `NoneProvider`, `SmtpProvider` (MailKit), `SendGridProvider`, `MailgunProvider`, `AzureProvider` +3. **Dispatch.Web** - ASP.NET Core host (port 8420 web UI + port 8025 API on separate listener), REST endpoints, `ApiMessageHandler`, `ApiKeyMiddleware`, SignalR hub, `EmbeddedFileProvider`, `LogRepository`, `ConfigRepository`, `ApiKeyRepository` +4. **Dispatch.UI** - React SPA; wire Vite build into Dispatch.Web via MSBuild `EmbeddedResource` +5. **Dispatch.Service** - `Program.cs`; wire `SpoolWorkerPool` + `PurgeWorker` as `IHostedService` +6. **Dispatch.Bootstrap.Windows** - WinForms setup wizard (SQL detect/install, schema migration, MSI launch) +7. **installer/windows** - WiX v6 `.wxs` MSI; WiX Burn chain wrapping `DispatchSetup.exe` +8. **installer/linux** - systemd unit + `install.sh` ### 19.2 SmtpServer Wiring Pattern @@ -3532,7 +3552,7 @@ await smtpServer.StartAsync(cancellationToken); The spool directory is the heart of Dispatch. Keep all spool logic in `Dispatch.Core` so it is testable independently of ASP.NET Core or SQL. -**`SpoolDirectory` — path helper and doorbell:** +**`SpoolDirectory` - path helper and doorbell:** ```csharp public class SpoolDirectory @@ -3567,7 +3587,7 @@ public class SpoolDirectory } ``` -**`SpoolMessageStore` — the only thing between DATA and 250 OK:** +**`SpoolMessageStore` - the only thing between DATA and 250 OK:** ```csharp public class SpoolMessageStore(SpoolDirectory spool, ISessionContext ctx) : IMessageStore @@ -3582,10 +3602,10 @@ public class SpoolMessageStore(SpoolDirectory spool, ISessionContext ctx) : IMes var bytes = buffer.ToArray(); var path = spool.IncomingPath(id); - // Write raw bytes — no parsing, no DB, no network call + // Write raw bytes - no parsing, no DB, no network call await File.WriteAllBytesAsync(path, bytes, ct); - // Parse just enough to populate the meta (from/to/tags only — not full MIME) + // Parse just enough to populate the meta (from/to/tags only - not full MIME) // MimeKit.MimeMessage.Load is fast for header-only parsing var msg = MimeMessage.Load(new MemoryStream(bytes)); var tags = msg.Headers @@ -3613,7 +3633,7 @@ public class SpoolMessageStore(SpoolDirectory spool, ISessionContext ctx) : IMes } ``` -**`SpoolMeta` — retry sidecar (written by the worker, not the receive path):** +**`SpoolMeta` - retry sidecar (written by the worker, not the receive path):** ```csharp public class SpoolMeta @@ -3630,12 +3650,12 @@ public class SpoolMeta public int? LastRelayId { get; set; } // set after first dispatch attempt public string? LastError { get; set; } - // Full load — used by worker after claiming file + // Full load - used by worker after claiming file public static SpoolMeta Load(string emlPath) => JsonSerializer.Deserialize( File.ReadAllText(Path.ChangeExtension(emlPath, ".meta")))!; - // Peek — reads meta without requiring the .eml; used by relay-aware claim loop + // Peek - reads meta without requiring the .eml; used by relay-aware claim loop // Returns null if meta doesn't exist yet (file just written, meta not yet created) public static SpoolMeta? Peek(string emlPath) { @@ -3647,7 +3667,7 @@ public class SpoolMeta } catch { - return null; // meta corrupt or being written — skip this file + return null; // meta corrupt or being written - skip this file } } @@ -3658,7 +3678,7 @@ public class SpoolMeta } ``` -**Atomic file claim — multiple workers, no double-delivery:** +**Atomic file claim - multiple workers, no double-delivery:** ```csharp private string? ClaimNextFile() @@ -3669,17 +3689,17 @@ private string? ClaimNextFile() var dest = _spool.ProcessingPath(Path.GetFileName(next)); try { - File.Move(next, dest); // atomic on same filesystem — first worker wins + File.Move(next, dest); // atomic on same filesystem - first worker wins return dest; } catch (IOException) { - return null; // another worker got it — safe to ignore + return null; // another worker got it - safe to ignore } } ``` -**`FileSystemWatcher` — supplement to the Channel doorbell:** +**`FileSystemWatcher` - supplement to the Channel doorbell:** ```csharp // In SpoolWorkerPool.StartAsync() @@ -3708,7 +3728,7 @@ private void RecoverOrphans() } ``` -**`ILogRepository` — the only SQL touch point in the worker:** +**`ILogRepository` - the only SQL touch point in the worker:** ```csharp public interface ILogRepository @@ -3736,7 +3756,7 @@ public interface ILogRepository ``` ```csharp -// Program.cs — serve embedded files +// Program.cs - serve embedded files var embeddedProvider = new EmbeddedFileProvider( typeof(Program).Assembly, baseNamespace: "Dispatch.Web.wwwroot"); @@ -3747,7 +3767,7 @@ app.UseStaticFiles(new StaticFileOptions RequestPath = "" }); -// SPA fallback — send index.html for unknown routes so React Router works +// SPA fallback - send index.html for unknown routes so React Router works app.MapFallbackToFile("index.html", new StaticFileOptions { FileProvider = embeddedProvider @@ -3756,24 +3776,25 @@ app.MapFallbackToFile("index.html", new StaticFileOptions ### 19.5 Configuration Encryption -`SecureConfig` encrypts/decrypts sensitive values stored in the SQL `config` table (rows where `encrypted = 1`). The connection string in `appsettings.json` is stored in plaintext — it grants access only to a least-privilege `dispatch_app` SQL login, so exposure risk is low and file-level OS permissions provide the primary protection. +`SecureConfig` encrypts/decrypts sensitive values stored in the SQL `config` table (rows where `encrypted = 1`). The connection string in `appsettings.json` is stored in plaintext - it grants access only to a least-privilege `dispatch_app` SQL login, so exposure risk is low and file-level OS permissions provide the primary protection. ```csharp public static class SecureConfig { - // Windows: DPAPI ProtectedData.Protect/Unprotect (machine scope) - // Linux: PBKDF2 from /etc/machine-id + fixed app salt → AesGcm + // All platforms: AES-256-GCM with a random key in a portable, access-restricted .dispatch-key file + // (machine-derived PBKDF2 key only as a fallback when no writable key dir is available). + // Decrypt also reads legacy Windows DPAPI blobs and they migrate to AES on the next save. public static string Encrypt(string plaintext) { ... } public static string Decrypt(string ciphertext) { ... } } ``` -`ConfigRepository` calls `Encrypt()` before writing any row with `encrypted = 1` and `Decrypt()` transparently after reading. The raw plaintext never touches `appsettings.json`. Never log or surface the decrypted value — only write it to the provider SDK call. +`ConfigRepository` calls `Encrypt()` before writing any row with `encrypted = 1` and `Decrypt()` transparently after reading. The raw plaintext never touches `appsettings.json`. Never log or surface the decrypted value - only write it to the provider SDK call. ### 19.6 In-Memory Ring Buffer Sink (Serilog) ```csharp -// Custom Serilog sink — keeps last N events in a ConcurrentQueue +// Custom Serilog sink - keeps last N events in a ConcurrentQueue // SignalR hub reads from this on connect to replay recent events // New events are pushed to connected clients via IHubContext public class RingBufferSink : ILogEventSink @@ -3794,7 +3815,7 @@ public class RingBufferSink : ILogEventSink ### 19.7 Routing and Provider Resolution -The `RoutingEngine` is called on every spool file dispatch. Relay configs and routing rules are loaded from SQL via the `IRelayRepository` and `IRoutingRuleRepository` — cache them with a short TTL (e.g. 5 seconds) in the repositories so rule changes propagate quickly without a SQL query per message: +The `RoutingEngine` is called on every spool file dispatch. Relay configs and routing rules are loaded from SQL via the `IRelayRepository` and `IRoutingRuleRepository` - cache them with a short TTL (e.g. 5 seconds) in the repositories so rule changes propagate quickly without a SQL query per message: ```csharp // SpoolWorkerPool.ProcessAsync() @@ -3807,13 +3828,13 @@ var provider = _providerFactory.Build(relay); // new instance per dispatch var result = await provider.SendAsync(message, ct); ``` -`IRelayProviderFactory.Build(RelayConfig)` reads `relay:{id}:*` keys from the `config` table and constructs the appropriate `IRelayProvider`. Because a new instance is built per dispatch, credential changes and provider type changes take effect immediately on the next message — no restart, no singleton state to worry about. +`IRelayProviderFactory.Build(RelayConfig)` reads `relay:{id}:*` keys from the `config` table and constructs the appropriate `IRelayProvider`. Because a new instance is built per dispatch, credential changes and provider type changes take effect immediately on the next message - no restart, no singleton state to worry about. --- ### 19.8 Bootstrap Installer Implementation (Windows) -`Dispatch.Bootstrap.Windows` is a .NET 9 WinForms project published as a self-contained single-file EXE. It has no .NET runtime prerequisite — use `PublishSingleFile=true` with `SelfContained=true`. +`Dispatch.Bootstrap.Windows` is a .NET 10 WinForms project published as a self-contained single-file EXE. It has no .NET runtime prerequisite - use `PublishSingleFile=true` with `SelfContained=true`. **SQL Server detection:** @@ -3829,7 +3850,7 @@ public static async Task> DetectLocalInstancesAsync() found.Add(candidate); } - // 2. Enumerate via SQL Server Browser (best-effort — Browser may be disabled) + // 2. Enumerate via SQL Server Browser (best-effort - Browser may be disabled) try { var table = SqlDataSourceEnumerator.Instance.GetDataSources(); @@ -3842,7 +3863,7 @@ public static async Task> DetectLocalInstancesAsync() found.Add(name); } } - catch { /* Browser disabled — ignore */ } + catch { /* Browser disabled - ignore */ } return found; } @@ -3860,8 +3881,8 @@ public static async Task InitialiseDatabaseAsync(string connectionString) using (var conn = new SqlConnection(builder.ToString())) { await conn.ExecuteAsync(""" - IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'DispatchQueue') - CREATE DATABASE DispatchQueue; + IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name = 'DispatchLog') + CREATE DATABASE DispatchLog; """); } @@ -4032,7 +4053,7 @@ public class RelaySemaphorePool _semaphores[relayId] = newSem; } - // For GET /api/relays/concurrency — reads in-memory state, no SQL + // For GET /api/relays/concurrency - reads in-memory state, no SQL public IReadOnlyDictionary GetInFlightCounts() { return _semaphores.ToDictionary( @@ -4089,12 +4110,10 @@ The Dashboard can poll this endpoint every 5 seconds to show the live In Flight | Item | Detail | |---|---| -| Licence | AGPL-3.0 + Commons Clause | -| Licence summary | Free to use, modify, and self-host. You may not sell Dispatch or a hosted version of it. | +| Licence | Apache License 2.0 | +| Licence summary | Free and open source. No license key is required to run Dispatch. | | Repository | github.com/yourorg/dispatch | -| Contribution guide | CONTRIBUTING.md — PR checklist, coding style, test requirements | | Issue templates | Bug report, feature request, provider request | -| Code of conduct | Contributor Covenant | | Release cadence | Semantic versioning; minor releases monthly; patch releases as needed | ### 19.9 Database Migration Runner @@ -4194,7 +4213,7 @@ public record MigrationResult(int AppliedCount, List Scripts); **CLI entry point** (for Linux `install.sh` to call): ```csharp -// Dispatch.Service / Program.cs — handle CLI utility flags +// Dispatch.Service / Program.cs - handle CLI utility flags if (args.Contains("--migrate-only")) { var connStr = configuration.GetConnectionString("DispatchLog")!; @@ -4240,7 +4259,7 @@ if (args.Contains("--trust-cert")) ### 19.10 Spool Drain Endpoint -The `/api/service/drain` endpoint is called by the Windows bootstrap and Linux `install.sh` before stopping the service for an upgrade. It waits for `spool/processing/` to empty — meaning all in-flight dispatches have either succeeded or been moved to `failed/`. +The `/api/service/drain` endpoint is called by the Windows bootstrap and Linux `install.sh` before stopping the service for an upgrade. It waits for `spool/processing/` to empty - meaning all in-flight dispatches have either succeeded or been moved to `failed/`. ```csharp app.MapPost("/api/service/drain", async ( @@ -4262,7 +4281,7 @@ app.MapPost("/api/service/drain", async ( await Task.Delay(TimeSpan.FromSeconds(2), ct); } - // Timeout — move any stuck processing files back to incoming + // Timeout - move any stuck processing files back to incoming // so they are retried after the upgraded service starts var recovered = 0; foreach (var eml in Directory.EnumerateFiles(spool.ProcessingDir, "*.eml")) @@ -4284,7 +4303,7 @@ app.MapPost("/api/service/drain", async ( }); ``` -No SQL involved — drain checks the filesystem only. Files moved back to `incoming/` will be picked up automatically when the upgraded service starts and `RecoverOrphans()` runs. +No SQL involved - drain checks the filesystem only. Files moved back to `incoming/` will be picked up automatically when the upgraded service starts and `RecoverOrphans()` runs. ### 19.12 Message Log Query Implementation @@ -4340,7 +4359,7 @@ ORDER BY logged_at DESC, id DESC; The cursor is encoded as `base64(logged_at.ticks + ":" + id)` and returned in the response alongside the rows. The UI passes it back on the next "Load more" click. No `OFFSET`, no `COUNT(*)`. -**Slow-path filters** — subject and tag filters add predicates that cannot use the covering indexes. When these are present, add a note to the response: +**Slow-path filters** - subject and tag filters add predicates that cannot use the covering indexes. When these are present, add a note to the response: ```json { @@ -4374,7 +4393,7 @@ public class LogHub : Hub if (batch.Count > 0) { - // Send newest 20 only — client keeps its own ring buffer + // Send newest 20 only - client keeps its own ring buffer var toSend = batch.TakeLast(20).ToArray(); await Clients.All.SendAsync("LogBatch", toSend, ct); batch.Clear(); @@ -4396,7 +4415,7 @@ app.MapPost("/api/messages/export", async ( { var jobId = Guid.NewGuid().ToString("N")[..12]; - // Fire and forget — streams rows to a temp file + // Fire and forget - streams rows to a temp file jobs.Enqueue(async ct => { var path = Path.Combine(Path.GetTempPath(), $"dispatch-export-{jobId}.csv"); @@ -4419,14 +4438,14 @@ app.MapPost("/api/messages/export", async ( }); ``` -`ILogRepository.StreamAsync()` uses `SqlDataReader` with `CommandBehavior.SequentialAccess` to stream rows without materialising all of them into memory — essential for large exports. The temp file is deleted 10 minutes after the download link is first served. +`ILogRepository.StreamAsync()` uses `SqlDataReader` with `CommandBehavior.SequentialAccess` to stream rows without materialising all of them into memory - essential for large exports. The temp file is deleted 10 minutes after the download link is first served. **Throughput aggregation:** When success logging is **on**, the sparkline reads from `relay_log`: ```sql --- 60 one-minute buckets, last 60 minutes — hits IX_relay_log_status_date +-- 60 one-minute buckets, last 60 minutes - hits IX_relay_log_status_date SELECT DATEADD(MINUTE, DATEDIFF(MINUTE, 0, logged_at), 0) AS bucket, COUNT(*) AS delivered FROM relay_log @@ -4461,17 +4480,15 @@ A background timer fires at the top of each minute, resets the slot that just ro |---|---| -| Licence | AGPL-3.0 + Commons Clause | -| Licence summary | Free to use, modify, and self-host. You may not sell Dispatch or a hosted version of it. | +| Licence | Apache License 2.0 | +| Licence summary | Free and open source. No license key is required to run Dispatch. | | Repository | github.com/yourorg/dispatch | -| Contribution guide | CONTRIBUTING.md — PR checklist, coding style, test requirements | | Issue templates | Bug report, feature request, provider request | -| Code of conduct | Contributor Covenant | | Release cadence | Semantic versioning; minor releases monthly; patch releases as needed | --- -## Appendix A — Suggested Future Providers +## Appendix A - Suggested Future Providers - Postmark - SparkPost / Bird @@ -4481,11 +4498,11 @@ A background timer fires at the top of each minute, resets the slot that just ro --- -## Appendix B — Suggested Future Features +## Appendix B - Suggested Future Features - Per-sender routing rules (route emails from app-a to Mailgun, app-b to SendGrid) - Header rewriting (overwrite From, Reply-To before relay) -- Webhooks — POST to a configurable URL on each delivery or failure event +- Webhooks - POST to a configurable URL on each delivery or failure event - Basic analytics dashboard (open/click tracking via provider APIs) - Docker image published to GitHub Container Registry (GHCR) - Kubernetes Helm chart diff --git a/docs/images/dashboard.png b/docs/images/dashboard.png new file mode 100644 index 00000000..f672516e Binary files /dev/null and b/docs/images/dashboard.png differ diff --git a/docs/images/message-log.png b/docs/images/message-log.png new file mode 100644 index 00000000..6d28f3a7 Binary files /dev/null and b/docs/images/message-log.png differ diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 00000000..96d7a4cf --- /dev/null +++ b/installer/README.md @@ -0,0 +1,78 @@ +# Installers + +Per spec §12.1, the only things written to the platform config file are the **SQL connection string**, the +install-time **admin-password seed**, and (optionally) the **Web UI TLS cert**. Everything else (ports, +spool, retry, retention, …) is seeded into the SQL `config` table on first run and managed from the +dashboard. On first start the admin-password seed is hashed (bcrypt) into the `config` table; if you omit +it, the dashboard shows a one-time first-run setup screen instead. + +## Windows (`windows/`) + +Three ways to install, from most to least automated: + +### 1. Bundle (`windows/bundle/Bundle.wxs`) - recommended + +A WiX **Burn bundle** (`DispatchSetup.exe`) that chains, in order: + +1. **SQL Server Express** - `sql-express/InstallSqlExpress.exe` runs `InstallSqlExpress.ps1`, which downloads + and silently installs SQL Server Express as the named instance **`DISPATCHSQL`**, creates the + **`DispatchLog`** database, and grants `NT AUTHORITY\SYSTEM` sysadmin (the service runs as LocalSystem and + connects over shared memory with Windows auth). Skipped if the instance already exists. +2. **Dispatch MSI** (`Dispatch.wxs`) - installs the binaries + Windows service + firewall rules and writes + `%ProgramData%\Dispatch\appsettings.json` pointing at the local instance (via the `WriteAppSettings` + custom action). The admin password is set on first run in the dashboard. + +This pattern is adapted from the FluxDeploy installer (`packaging/sql-express-launcher`, `relay-bundle`). + +Build (Windows, .NET SDK + WiX v6 toolset): + +```powershell +cd installer\windows +dotnet publish ..\..\src\Dispatch.Service -c Release -o publish # (build + embed the UI first) +wix build Dispatch.wxs -d PublishDir=publish -ext WixToolset.Firewall.wixext -o Dispatch.msi +dotnet build sql-express\SqlExpressLauncher.csproj -c Release # -> InstallSqlExpress.exe +wix build bundle\Bundle.wxs -ext WixToolset.BootstrapperApplications.wixext -ext WixToolset.Util.wixext ` + -bindpath "SqlLauncher=sql-express\bin\Release\net472" -bindpath "Msi=." -o DispatchSetup.exe +``` + +To use an **existing** SQL Server instead of the local Express install, install the MSI directly and pass a +connection string: `msiexec /i Dispatch.msi SQLCONN="Server=...;Database=DispatchLog;User Id=...;Password=...;TrustServerCertificate=True;Encrypt=True"`. + +### 2. MSI only (`windows/Dispatch.wxs`) + +Binaries + service + firewall + `appsettings.json`. Defaults `SQLCONN` to the local `DISPATCHSQL` instance; +override via the `SQLCONN` property for an existing server. + +### 3. Script (`windows/install.ps1`) + +Elevated PowerShell scripted install (publishes, writes config, registers the service, opens firewall) for a +pre-existing SQL Server. Requires the .NET SDK + Node.js on the host. + +## Linux (`linux/`) + +`install.sh` publishes the service, lays out `/opt/dispatch`, `/etc/dispatch/appsettings.json` (mode 600), +`/var/lib/dispatch/spool`, `/var/log/dispatch`, creates the `dispatch` service account, and installs the +`dispatch.service` systemd unit. It can either use an existing SQL Server or install one locally. + +```bash +# Existing SQL Server / Azure SQL: +sudo ./linux/install.sh \ + --sql-connection "Server=...;Database=DispatchLog;User Id=...;Password=...;TrustServerCertificate=True;Encrypt=True" \ + --admin-password "" + +# Or install SQL Server Express locally (Ubuntu/Debian or RHEL/Fedora) + a self-signed dashboard cert: +sudo ./linux/install.sh --install-sql --sa-password "" \ + --admin-password "" --generate-cert +``` + +`--install-sql` adds Microsoft's `mssql-server` package (Express edition = free, via `MSSQL_PID=Express`), +runs the unattended setup, and creates the `DispatchLog` database. `--generate-cert` creates a self-signed +PFX and serves the dashboard over HTTPS (spec §17.2). Prerequisites: the .NET 10 SDK and Node.js (the script +builds the embedded UI and publishes); a prebuilt self-contained tarball is future work. + +## Validation status + +The Windows artifacts (PowerShell bootstrap, WiX MSI/bundle, the net472 launcher) and the Linux +`--install-sql` path were authored on macOS and **must be validated on the target OS** - they cannot be +built or executed here. The Linux `install.sh` passes `bash -n` syntax checks. The SQL-Express bootstrap and +bundle-chaining approach mirror the proven FluxDeploy installer. diff --git a/installer/linux/dispatch-update.path b/installer/linux/dispatch-update.path new file mode 100644 index 00000000..73b9ba37 --- /dev/null +++ b/installer/linux/dispatch-update.path @@ -0,0 +1,11 @@ +[Unit] +Description=Watch for a Dispatch upgrade request and run the updater +# The (unprivileged) Dispatch service stages a verified bundle and drops this file; systemd then starts the +# privileged updater. This keeps the service itself from needing root or systemctl access. + +[Path] +PathExists=/var/lib/dispatch/updates/apply.request +Unit=dispatch-updater.service + +[Install] +WantedBy=multi-user.target diff --git a/installer/linux/dispatch-update.sh b/installer/linux/dispatch-update.sh new file mode 100755 index 00000000..27753dad --- /dev/null +++ b/installer/linux/dispatch-update.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# Dispatch SMTP Relay - privileged update applier (Linux). +# +# Triggered (as root) by dispatch-update.path when the web service drops an apply.request after verifying + +# staging an uploaded upgrade bundle. This re-verifies the bundle independently, swaps the release symlink, +# restarts the service, and ROLLS BACK if the new version fails to come up. The web UI watches status.json. +# +# Trust: re-checks the detached signature against the shipped public key with openssl (defense-in-depth over +# the in-app C# check) and the payload sha256 against the manifest, both fail-closed. +# +set -uo pipefail + +INSTALL_DIR="/opt/dispatch" +DATA_DIR="/var/lib/dispatch" +UPDATES_DIR="$DATA_DIR/updates" +REQ="$UPDATES_DIR/apply.request" +PUBKEY="$INSTALL_DIR/dispatch-update-public.pem" +KEEP_RELEASES=3 + +[ -f "$REQ" ] || exit 0 + +VER="" +status() { + mkdir -p "$UPDATES_DIR" + printf '{"state":"%s","version":"%s","message":"%s","updatedAtUtc":"%s"}\n' \ + "$1" "$VER" "$2" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$UPDATES_DIR/status.json" +} +# Extract a flat top-level JSON string field ($1) from a file ($2). +jget() { grep -o "\"$1\"[ ]*:[ ]*\"[^\"]*\"" "$2" | head -1 | sed 's/.*:[ ]*"//; s/"$//'; } + +fail() { echo "update: $1" >&2; status "Failed" "$1"; rm -f "$REQ"; exit 1; } + +# apply.request is written by the UNPRIVILEGED service, so treat it as untrusted: take ONLY the staged-dir +# pointer from it (the authoritative version comes from the signed manifest below), and constrain that dir to +# strictly under /staged/ after resolving symlinks - so a compromised service account can't aim the +# root applier at an arbitrary path (traversal / symlink escape). +STAGED="$(jget stagedDir "$REQ")" +[ -n "$STAGED" ] || fail "apply.request is missing stagedDir" +STAGED="$(readlink -f "$STAGED" 2>/dev/null || true)" +STAGED_BASE="$(readlink -f "$UPDATES_DIR/staged" 2>/dev/null || true)" +[ -n "$STAGED" ] && [ -n "$STAGED_BASE" ] || fail "could not resolve the staged dir" +case "$STAGED/" in + "$STAGED_BASE"/*/) ;; + *) fail "staged dir is outside the updates area: $STAGED" ;; +esac +[ -d "$STAGED" ] || fail "staged payload not found: $STAGED" +[ -f "$PUBKEY" ] || fail "release public key not found: $PUBKEY" + +# This host's runtime id, to pick its entry out of the package manifest's artifacts map. +case "$(uname -m)" in + x86_64|amd64) ARCH="linux-x64" ;; + aarch64|arm64) ARCH="linux-arm64" ;; + *) ARCH="linux-$(uname -m)" ;; +esac + +# 1) Re-verify independently of the web service (fail-closed): the manifest signature (authenticity) FIRST, +# then take the version + payload hash from the now-trusted manifest. +echo "update: verifying signature ($ARCH)" +openssl dgst -sha256 -verify "$PUBKEY" -signature "$STAGED/manifest.json.sig" "$STAGED/manifest.json" >/dev/null 2>&1 \ + || fail "signature verification failed" + +# Authoritative version = the SIGNED manifest's version, and it must be a safe token (it lands in fs paths, +# an rm -rf target, and a T-SQL backup filename). Never trust apply.request's version for this. +VER="$(jget version "$STAGED/manifest.json")" +[ -n "$VER" ] || fail "signed manifest has no version" +case "$VER" in + *[!A-Za-z0-9._+-]*) fail "refusing unsafe version string from manifest: $VER" ;; +esac + +# Payload sha256 must match this arch's entry in the trusted manifest. Read the manifest with python via +# ARGV + env (never string-interpolated into the program text) so a crafted path/arch can't inject code. +if command -v python3 >/dev/null 2>&1; then + WANT="$(DISPATCH_ARCH="$ARCH" python3 - "$STAGED/manifest.json" 2>/dev/null <<'PY' +import json, os, sys +try: + m = json.load(open(sys.argv[1])) + print(m["artifacts"][os.environ["DISPATCH_ARCH"]]["sha256"]) +except Exception: + pass +PY +)" + GOT="$(sha256sum "$STAGED/payload" | cut -d' ' -f1)" + [ -n "$WANT" ] && [ "$WANT" = "$GOT" ] || fail "payload checksum mismatch for $ARCH" +else + echo "update: python3 absent - skipping the payload-hash recheck (signature already verified authenticity)" +fi + +status "Applying" "applying $VER" + +# 2) Best-effort DB backup before the restart (the new version auto-applies migrations on start). Never +# blocks the update: a backup failure (e.g. external SQL, no sqlcmd) is logged and skipped. +backup_db() { + command -v sqlcmd >/dev/null 2>&1 || { echo "update: sqlcmd not found, skipping DB backup"; return 0; } + command -v python3 >/dev/null 2>&1 || return 0 + # Read the connection string via python ARGV (not interpolated into the program text). + local conn; conn="$(python3 - "$DATA_DIR/appsettings.json" 2>/dev/null <<'PY' +import json, sys +try: + print(json.load(open(sys.argv[1]))["ConnectionStrings"]["DispatchLog"]) +except Exception: + pass +PY +)" + [ -n "$conn" ] || return 0 + local srv usr pwd db; + srv="$(sed -n 's/.*[Ss]erver=\([^;]*\).*/\1/p' <<<"$conn")" + usr="$(sed -n 's/.*[Uu]ser[ ]*[Ii]d=\([^;]*\).*/\1/p' <<<"$conn")" + pwd="$(sed -n 's/.*[Pp]assword=\([^;]*\).*/\1/p' <<<"$conn")" + db="$(sed -n 's/.*[Dd]atabase=\([^;]*\).*/\1/p' <<<"$conn")" + [ -n "$srv" ] && [ -n "$usr" ] && [ -n "$db" ] || { echo "update: could not parse connection, skipping DB backup"; return 0; } + # db lands in a T-SQL identifier + a filename; require a safe token (VER was validated against the manifest). + case "$db" in *[!A-Za-z0-9._-]*) echo "update: unsafe db name '$db', skipping DB backup"; return 0;; esac + mkdir -p "$DATA_DIR/backups" + local bak="$DATA_DIR/backups/${db}-pre-${VER}-$(date -u +%Y%m%dT%H%M%SZ).bak" + if sqlcmd -S "$srv" -U "$usr" -P "$pwd" -C -Q "BACKUP DATABASE [$db] TO DISK = N'$bak' WITH INIT, COMPRESSION;" >/dev/null 2>&1; then + echo "update: DB backed up to $bak" + else + echo "update: DB backup failed (continuing) - rollback is binary-level; migrations are backward-compatible" + fi +} +backup_db + +# 3) Extract the new release and flip the 'current' symlink (remembering the old target for rollback). +NEWDIR="$INSTALL_DIR/releases/$VER" +PREV="$(readlink -f "$INSTALL_DIR/current" 2>/dev/null || true)" +rm -rf "$NEWDIR"; mkdir -p "$NEWDIR" +# Hardened extraction: don't honour archived ownership or clobber dir metadata. Payload authenticity is +# already signature-bound, so this is defense-in-depth against a malicious release archive. +tar --no-same-owner --no-overwrite-dir -xzf "$STAGED/payload" -C "$NEWDIR" || fail "could not extract the payload" +chmod +x "$NEWDIR/Dispatch.Service" 2>/dev/null || true +chown -R dispatch:dispatch "$NEWDIR" 2>/dev/null || true +ln -sfn "$NEWDIR" "$INSTALL_DIR/current" + +# 4) Restart. The unit is Type=notify, so `systemctl restart` only returns success once the new version has +# fully started (DB migrations applied, listeners bound, READY signalled) - that IS the health gate. +echo "update: restarting service on $VER" +status "Restarting" "restarting service on $VER (the dashboard will briefly disconnect)" +if systemctl restart dispatch; then + status "Succeeded" "updated to $VER" + echo "update: now running $VER" + # Prune old releases, keeping the newest few (never the current target). + ls -1dt "$INSTALL_DIR"/releases/*/ 2>/dev/null | tail -n +$((KEEP_RELEASES + 1)) | while read -r d; do + [ "$(readlink -f "$d")" = "$PREV" ] && continue + [ "$(readlink -f "$d")" = "$(readlink -f "$INSTALL_DIR/current")" ] && continue + rm -rf "$d" + done + rm -f "$REQ" + exit 0 +else + echo "update: new version failed to start - rolling back" >&2 + if [ -n "$PREV" ] && [ -d "$PREV" ]; then + ln -sfn "$PREV" "$INSTALL_DIR/current" + systemctl restart dispatch || true + status "RolledBack" "update to $VER failed to start; rolled back to the previous version" + else + status "Failed" "update to $VER failed to start and no previous release was available to roll back to" + fi + rm -f "$REQ" + exit 1 +fi diff --git a/installer/linux/dispatch-updater.service b/installer/linux/dispatch-updater.service new file mode 100644 index 00000000..d6ab8b8f --- /dev/null +++ b/installer/linux/dispatch-updater.service @@ -0,0 +1,10 @@ +[Unit] +Description=Dispatch SMTP Relay - apply a staged upgrade +# Started (as root) by dispatch-update.path when the service stages a verified upgrade. Oneshot: it applies +# the staged payload, restarts dispatch.service, and rolls back if the new version fails to come up. + +[Service] +Type=oneshot +ExecStart=/opt/dispatch/dispatch-update.sh +# Intentionally unsandboxed - it must write /opt/dispatch and call systemctl. It runs only the shipped, +# signature-verified updater script, and only when a verified bundle has been staged. diff --git a/installer/linux/dispatch.service b/installer/linux/dispatch.service new file mode 100644 index 00000000..1bead347 --- /dev/null +++ b/installer/linux/dispatch.service @@ -0,0 +1,40 @@ +[Unit] +Description=Dispatch SMTP Relay +After=network.target + +[Service] +# notify: the service signals readiness via sd_notify (Program.cs UseSystemd) so systemd knows when it's up. +Type=notify +User=dispatch +Group=dispatch +# Binaries live under /opt/dispatch/releases/ with /opt/dispatch/current symlinked to the active one +# (so the updater swaps releases atomically by re-pointing the link); appsettings.json + the spool live +# under the data dir (the content root). +WorkingDirectory=/var/lib/dispatch +ExecStart=/opt/dispatch/current/Dispatch.Service +Environment=DOTNET_ENVIRONMENT=Production +Environment=DISPATCH_LOG_DIR=/var/log/dispatch +Restart=always +RestartSec=5 +# Bind the privileged SMTP ports (25, 587) as the unprivileged 'dispatch' user. Without this the +# listener can't bind them and falls back to 2525. AmbientCapabilities grants the capability to the +# process; CapabilityBoundingSet limits it to just this one (and it must be in the bounding set for the +# ambient grant to take). NoNewPrivileges stays true - ambient caps are granted by systemd at exec and +# are unaffected by it. +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE +# Hardening +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=true +PrivateTmp=true +# Spool .eml/.meta files contain message content (spec §14.5): force mode 600 (rw-------) on every +# file the service creates so message bodies are never group/world-readable. Use 0077 (NOT 0177): +# both make files 0600, but 0177 also strips the directory execute bit, so directories the service +# creates come out drw------- and can't be descended into - which broke web-UI update staging +# (creating updates/staged// failed with "Permission denied"). 0077 keeps files 0600, dirs 0700. +UMask=0077 +ReadWritePaths=/var/lib/dispatch /var/log/dispatch + +[Install] +WantedBy=multi-user.target diff --git a/installer/linux/install.sh b/installer/linux/install.sh new file mode 100755 index 00000000..b5f68f1c --- /dev/null +++ b/installer/linux/install.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash +# +# Dispatch SMTP Relay - Linux installer. +# +# Publishes the service, lays out config/data directories, and installs a systemd unit. Per spec §12.1 the +# only things written to appsettings.json are the SQL connection string, the install-time admin-password +# seed, and (optionally) the Web UI TLS cert - everything else lives in the SQL config table and is managed +# from the dashboard after first run (default ports: SMTP 25 & 587, dashboard 8420, API 8025). The service +# runs as the unprivileged 'dispatch' user but the systemd unit grants CAP_NET_BIND_SERVICE so it can bind +# 25/587; if 25 is already taken the listener falls back to 2525 automatically. +# Recommendation: install on a host with no other SMTP software (Postfix, Sendmail, Exim, …) so 25/587 are free. +# +# Usage: +# # Use an existing SQL Server / Azure SQL: +# sudo ./install.sh --sql-connection "Server=...;Database=DispatchLog;User Id=...;Password=...;TrustServerCertificate=True;Encrypt=True" --admin-password "" +# +# # Or have the installer set up SQL Server Express locally (Ubuntu/Debian or RHEL/Fedora): +# sudo ./install.sh --install-sql --sa-password "" --admin-password "" [--generate-cert] +# +# # From a release tarball (self-contained binaries; no .NET SDK / Node required on the box): +# sudo ./install.sh --prebuilt ./bin --install-sql --sa-password "" --admin-password "" +# +# Flags: +# --sql-connection Connection string for an existing server (omit when using --install-sql). +# --install-sql Install SQL Server (Express, free) locally + create the DispatchLog DB. On arm64 +# (no SQL Server build) this runs Azure SQL Edge in a container instead - needs Docker. +# --sa-password SA password for --install-sql (required with --install-sql; must meet SQL policy). +# --admin-password Dashboard admin password seed (prompted if omitted). +# --generate-cert Generate a self-signed PFX and serve the dashboard over HTTPS (spec §17.2). +# --http-port Firewall/URL dashboard port (default 8420; change in the dashboard to differ). +# --api-port Firewall ingestion API port (default 8025). +# --smtp-ports Firewall SMTP ports (default 25,587; the listener falls back to 2525 if 25 is taken). +# --source Repo source root (default: two levels up from this script). Build-from-source mode. +# --prebuilt

Install pre-published self-contained binaries from instead of building from +# source. Used by the release tarball; needs neither the .NET SDK nor Node. +# --no-start Stage the install but don't start the service (the unit is enabled, not started). +# Used when baking the appliance image; first boot configures SQL and starts it. +# +set -euo pipefail + +INSTALL_DIR="/opt/dispatch" +DATA_DIR="/var/lib/dispatch" +# Config + spool live together under the data dir (the service's content root), mirroring the Windows +# ProgramData layout. The spool default (./.dispatch-spool, from the SQL config) resolves under here. +CONFIG_DIR="$DATA_DIR" +LOG_DIR="/var/log/dispatch" +HTTP_PORT="8420" +API_PORT="8025" +SMTP_PORTS="25,587" +SQL_CONNECTION="" +ADMIN_PASSWORD="" +SOURCE_DIR="" +PREBUILT_DIR="" +INSTALL_SQL="0" +SA_PASSWORD="" +GENERATE_CERT="0" +TLS_CERT_PATH="" +TLS_CERT_PASSWORD="" +NO_START="0" + +while [[ $# -gt 0 ]]; do + case "$1" in + --sql-connection) SQL_CONNECTION="$2"; shift 2;; + --admin-password) ADMIN_PASSWORD="$2"; shift 2;; + --install-sql) INSTALL_SQL="1"; shift;; + --sa-password) SA_PASSWORD="$2"; shift 2;; + --generate-cert) GENERATE_CERT="1"; shift;; + --http-port) HTTP_PORT="$2"; shift 2;; + --api-port) API_PORT="$2"; shift 2;; + --smtp-ports) SMTP_PORTS="$2"; shift 2;; + --source) SOURCE_DIR="$2"; shift 2;; + --prebuilt) PREBUILT_DIR="$2"; shift 2;; + # Stage the install without starting the service (the unit is enabled, not started). Used when building + # the prebuilt appliance image, where SQL is configured and the service is started on first boot. + --no-start) NO_START="1"; shift;; + *) echo "Unknown option: $1" >&2; exit 1;; + esac +done + +[[ $EUID -eq 0 ]] || { echo "Please run as root (sudo)." >&2; exit 1; } + +# ---- Optional: install SQL Server Express locally ----------------------------------------------- +# Installs Microsoft's mssql-server (Express edition = free, set via MSSQL_PID), runs the unattended +# setup, and creates the DispatchLog database. Best-effort across Debian/Ubuntu (apt) and RHEL/Fedora +# (dnf/yum); validate on your target distro. Sets SQL_CONNECTION to the local SA connection. +install_sql_server() { + [[ -n "$SA_PASSWORD" ]] || { echo "--sa-password is required with --install-sql." >&2; exit 1; } + . /etc/os-release + + # SQL Server has no arm64 Linux build, so on arm64 fall back to Azure SQL Edge, which is arm64-native + # but ships only as a container image. This is a dev/test convenience - SQL Edge is deprecated by + # Microsoft; for production use an amd64 host with SQL Server, or point --sql-connection at an external + # instance. + local arch; arch="$(uname -m)" + if [[ "$arch" == "aarch64" || "$arch" == "arm64" ]]; then + install_sql_edge_container + return + fi + + echo "==> Installing SQL Server (Express) for $ID $VERSION_ID ($arch)" + if command -v apt-get >/dev/null 2>&1; then + # Prerequisites the rest of this function needs (a minimal cloud/VM image often lacks gnupg + the + # https apt transport); install them before using gpg so the key import can't fail with "gpg: not found". + apt-get update -y || true + apt-get install -y curl ca-certificates gnupg apt-transport-https + # SQL Server 2025 (supports Ubuntu 24.04). The 2025 .list uses signed-by=/usr/share/keyrings/microsoft-prod.gpg, + # so install the Microsoft key there. + install -d -m 0755 /usr/share/keyrings + curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --batch --yes --dearmor -o /usr/share/keyrings/microsoft-prod.gpg + chmod a+r /usr/share/keyrings/microsoft-prod.gpg + # Use the repo list for this distro release; fall back to the newest known LTS list if MS has no list + # for this exact ${VERSION_ID} yet (e.g. a brand-new Ubuntu before the SQL repo catches up). + curl -fsSL "https://packages.microsoft.com/config/${ID}/${VERSION_ID}/mssql-server-2025.list" -o /etc/apt/sources.list.d/mssql-server-2025.list || \ + curl -fsSL "https://packages.microsoft.com/config/ubuntu/24.04/mssql-server-2025.list" -o /etc/apt/sources.list.d/mssql-server-2025.list + apt-get update -y + ACCEPT_EULA=Y MSSQL_PID=Express MSSQL_SA_PASSWORD="$SA_PASSWORD" apt-get install -y mssql-server + apt-get install -y mssql-tools18 unixodbc-dev || true + elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then + local PM; PM="$(command -v dnf || command -v yum)" + curl -fsSL "https://packages.microsoft.com/config/rhel/${VERSION_ID%%.*}/mssql-server-2025.repo" -o /etc/yum.repos.d/mssql-server-2025.repo + ACCEPT_EULA=Y MSSQL_PID=Express MSSQL_SA_PASSWORD="$SA_PASSWORD" "$PM" install -y mssql-server + "$PM" install -y mssql-tools18 unixODBC-devel || true + else + echo "Unsupported package manager - install SQL Server manually and pass --sql-connection." >&2; exit 1 + fi + + echo "==> Running unattended SQL Server setup (Express edition)" + MSSQL_PID=Express MSSQL_SA_PASSWORD="$SA_PASSWORD" ACCEPT_EULA=Y /opt/mssql/bin/mssql-conf -n setup accept-eula + systemctl enable --now mssql-server + + echo "==> Waiting for SQL Server and creating the DispatchLog database" + local sqlcmd; sqlcmd="$(command -v sqlcmd || echo /opt/mssql-tools18/bin/sqlcmd)" + for _ in $(seq 1 30); do + if "$sqlcmd" -S localhost -U sa -P "$SA_PASSWORD" -C -Q "SELECT 1" >/dev/null 2>&1; then break; fi + sleep 5 + done + "$sqlcmd" -S localhost -U sa -P "$SA_PASSWORD" -C -Q "IF DB_ID('DispatchLog') IS NULL CREATE DATABASE [DispatchLog];" + SQL_CONNECTION="Server=localhost;Database=DispatchLog;User Id=sa;Password=${SA_PASSWORD};TrustServerCertificate=True;Encrypt=True" +} + +# ---- arm64 fallback: Azure SQL Edge in a container --------------------------------------------- +# SQL Server is amd64-only on Linux; Azure SQL Edge speaks the same wire protocol and runs natively on +# arm64. Needs a container runtime (docker/podman); the DispatchLog database is created by the service's +# own DatabaseInitializer on first start, so no sqlcmd is required. +install_sql_edge_container() { + echo "==> arm64 detected - SQL Server has no arm64 Linux build; using Azure SQL Edge (container) instead." + local runtime="" + if command -v docker >/dev/null 2>&1; then runtime="docker" + elif command -v podman >/dev/null 2>&1; then runtime="podman" + elif command -v apt-get >/dev/null 2>&1; then + echo "==> Installing a container runtime (docker.io)" + apt-get update -y || true + if apt-get install -y docker.io; then + systemctl enable --now docker || true + runtime="docker" + fi + fi + [[ -n "$runtime" ]] || { + echo "No container runtime found and Docker could not be installed automatically." >&2 + echo "Install Docker or Podman and re-run, or pass --sql-connection to an external SQL instance." >&2 + exit 1 + } + + echo "==> Starting Azure SQL Edge via $runtime (instance 'dispatch-sql' on port 1433)" + "$runtime" rm -f dispatch-sql >/dev/null 2>&1 || true + "$runtime" run -d --name dispatch-sql \ + -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=${SA_PASSWORD}" \ + -p 1433:1433 -v dispatch-sql-data:/var/opt/mssql \ + --restart unless-stopped \ + mcr.microsoft.com/azure-sql-edge:latest + + echo "==> Waiting for Azure SQL Edge to accept TCP on 1433" + for _ in $(seq 1 36); do + if (exec 3<>/dev/tcp/127.0.0.1/1433) 2>/dev/null; then exec 3>&- 3<&-; break; fi + sleep 5 + done + # The service's DatabaseInitializer connects to master and creates DispatchLog on first start. + SQL_CONNECTION="Server=localhost,1433;Database=DispatchLog;User Id=sa;Password=${SA_PASSWORD};TrustServerCertificate=True;Encrypt=True" +} + +# ---- Optional: generate a self-signed TLS cert for the dashboard -------------------------------- +generate_cert() { + command -v openssl >/dev/null 2>&1 || { echo "openssl is required for --generate-cert." >&2; exit 1; } + echo "==> Generating a self-signed TLS certificate for the dashboard" + mkdir -p "$CONFIG_DIR" + TLS_CERT_PASSWORD="$(openssl rand -base64 24)" + TLS_CERT_PATH="$CONFIG_DIR/dispatch.pfx" + local tmp; tmp="$(mktemp -d)" + openssl req -x509 -newkey rsa:2048 -nodes -days 825 -subj "/CN=$(hostname -f 2>/dev/null || hostname)" \ + -keyout "$tmp/key.pem" -out "$tmp/cert.pem" >/dev/null 2>&1 + openssl pkcs12 -export -out "$TLS_CERT_PATH" -inkey "$tmp/key.pem" -in "$tmp/cert.pem" -passout "pass:${TLS_CERT_PASSWORD}" >/dev/null 2>&1 + rm -rf "$tmp" + chmod 600 "$TLS_CERT_PATH" +} + +[[ "$INSTALL_SQL" == "1" ]] && install_sql_server +[[ -n "$SQL_CONNECTION" ]] || { echo "Provide --sql-connection, or use --install-sql." >&2; exit 1; } + +# Prompt for the admin password only when omitted AND running interactively. In a non-interactive run +# (e.g. baking the appliance image) leave it unset - the dashboard requires the admin password to be set on +# first login, so an empty seed is safe and avoids a hang/failure on a missing TTY. +if [[ -z "$ADMIN_PASSWORD" && -t 0 ]]; then + read -rsp "Set the dashboard admin password: " ADMIN_PASSWORD; echo + [[ -n "$ADMIN_PASSWORD" ]] || { echo "Admin password is required." >&2; exit 1; } +fi + +[[ "$GENERATE_CERT" == "1" ]] && generate_cert + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Auto-select prebuilt binaries by CPU arch when --prebuilt wasn't given, so one tarball + one command +# works on any Linux. Prefer a per-arch dir (bin-x64 / bin-arm64) shipped in a universal tarball, then a +# plain bin/ (single-arch tarball). If none are found we fall through to build-from-source (needs the SDK). +if [[ -z "$PREBUILT_DIR" ]]; then + case "$(uname -m)" in + x86_64|amd64) _arch=x64 ;; + aarch64|arm64) _arch=arm64 ;; + *) _arch="" ;; + esac + if [[ -n "$_arch" && -d "$SCRIPT_DIR/bin-$_arch" ]]; then PREBUILT_DIR="$SCRIPT_DIR/bin-$_arch" + elif [[ -d "$SCRIPT_DIR/bin" ]]; then PREBUILT_DIR="$SCRIPT_DIR/bin" + fi + [[ -n "$PREBUILT_DIR" ]] && echo "==> Using prebuilt binaries for $(uname -m): $PREBUILT_DIR" +fi + +# Stop any previously-installed service first, otherwise its running binary is "Text file busy" and the +# upgrade copy fails. Best-effort: ignored on a fresh install where the unit doesn't exist yet. +systemctl stop dispatch 2>/dev/null || true + +# Binaries live under /opt/dispatch/releases/ with /opt/dispatch/current symlinked to the active one, +# so the web-UI updater can swap releases atomically by re-pointing 'current' (and roll back the same way). +mkdir -p "$INSTALL_DIR/releases" +REL_VER="base" +if [[ -n "$PREBUILT_DIR" ]]; then + # Release-tarball mode: copy the self-contained publish output as-is (no SDK / Node build). + # A relative --prebuilt is resolved against the current dir, then against the script's own dir, so + # `--prebuilt ./bin` works whether you run it from inside the extracted folder or by path. + if [[ ! -d "$PREBUILT_DIR" && -d "$SCRIPT_DIR/$PREBUILT_DIR" ]]; then PREBUILT_DIR="$SCRIPT_DIR/$PREBUILT_DIR"; fi + [[ -d "$PREBUILT_DIR" ]] || { echo "--prebuilt dir not found: $PREBUILT_DIR (cwd: $PWD, script: $SCRIPT_DIR)" >&2; exit 1; } + [[ -f "$PREBUILT_DIR/Dispatch.Service" ]] || { echo "--prebuilt dir has no Dispatch.Service executable: $PREBUILT_DIR" >&2; exit 1; } + [[ -f "$PREBUILT_DIR/.dispatch-version" ]] && REL_VER="$(tr -d '[:space:]' < "$PREBUILT_DIR/.dispatch-version")" + # A self-contained .NET build still needs the system ICU library for globalization (it aborts at + # startup without it) plus TLS roots - minimal images often lack both. + echo "==> Ensuring .NET runtime dependencies (ICU, CA certs)" + if command -v apt-get >/dev/null 2>&1; then + apt-get update -y >/dev/null 2>&1 || true + apt-get install -y ca-certificates || true + # On Debian/Ubuntu the ICU runtime is version-numbered (libicu74, libicu76, ...) - there is no plain + # "libicu" package. Resolve the highest-versioned one; fall back to libicu-dev which always pulls it. + icu_pkg="$(apt-cache --names-only search '^libicu[0-9]+$' 2>/dev/null | awk '{print $1}' | sort -V | tail -1)" + apt-get install -y "${icu_pkg:-libicu-dev}" || echo "WARN: could not install the ICU runtime - install libicu manually if the service fails to start." >&2 + elif command -v dnf >/dev/null 2>&1; then dnf install -y libicu ca-certificates || true + elif command -v yum >/dev/null 2>&1; then yum install -y libicu ca-certificates || true + fi + REL_DIR="$INSTALL_DIR/releases/$REL_VER" + echo "==> Installing pre-built binaries from $PREBUILT_DIR to $REL_DIR" + rm -rf "$REL_DIR"; mkdir -p "$REL_DIR" + cp -r "$PREBUILT_DIR/." "$REL_DIR/" + chmod +x "$REL_DIR/Dispatch.Service" +else + # Build-from-source mode: needs the .NET SDK + Node. + SOURCE_DIR="${SOURCE_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}" + echo "==> Building the web UI" + ( cd "$SOURCE_DIR/src/Dispatch.UI" && npm ci && npm run build ) + rm -rf "$SOURCE_DIR/src/Dispatch.Web/wwwroot" + mkdir -p "$SOURCE_DIR/src/Dispatch.Web/wwwroot" + cp -r "$SOURCE_DIR/src/Dispatch.UI/dist/." "$SOURCE_DIR/src/Dispatch.Web/wwwroot/" + + REL_DIR="$INSTALL_DIR/releases/$REL_VER" + echo "==> Publishing the service to $REL_DIR" + rm -rf "$REL_DIR"; mkdir -p "$REL_DIR" + dotnet publish "$SOURCE_DIR/src/Dispatch.Service" -c Release -o "$REL_DIR" +fi +# Point 'current' at this release, then clear any old flat-layout binaries (pre-symlink installs put the +# executable + dlls directly under /opt/dispatch) so only the symlinked release is ever run. +ln -sfn "$REL_DIR" "$INSTALL_DIR/current" +rm -f "$INSTALL_DIR/Dispatch.Service" 2>/dev/null || true +find "$INSTALL_DIR" -maxdepth 1 -type f -name '*.dll' -delete 2>/dev/null || true + +echo "==> Creating the 'dispatch' service account and directories" +id -u dispatch >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin dispatch +mkdir -p "$CONFIG_DIR" "$DATA_DIR/.dispatch-spool" "$LOG_DIR" + +echo "==> Writing $CONFIG_DIR/appsettings.json" +# Spec §12.1: appsettings holds ONLY the connection string, the admin-password seed, and the Web UI TLS +# cert. Ports/spool/retry/etc. are seeded into the SQL config table on first run and managed in the dashboard. +WEBUI_TLS="" +if [[ -n "$TLS_CERT_PATH" ]]; then + WEBUI_TLS=", + \"WebUi\": { \"TlsCertPath\": \"${TLS_CERT_PATH//\"/\\\"}\", \"TlsCertPassword\": \"${TLS_CERT_PASSWORD//\"/\\\"}\" }" +fi +cat > "$CONFIG_DIR/appsettings.json" < Installing systemd units + the web-UI updater" +# Units/scripts live next to this script in a release tarball, or under installer/linux in a source tree. +UNIT_SRC="$SCRIPT_DIR"; [[ -f "$SCRIPT_DIR/dispatch.service" ]] || UNIT_SRC="$SOURCE_DIR/installer/linux" +install -m 644 "$UNIT_SRC/dispatch.service" /etc/systemd/system/dispatch.service +install -m 644 "$UNIT_SRC/dispatch-updater.service" /etc/systemd/system/dispatch-updater.service +install -m 644 "$UNIT_SRC/dispatch-update.path" /etc/systemd/system/dispatch-update.path +install -m 755 "$UNIT_SRC/dispatch-update.sh" "$INSTALL_DIR/dispatch-update.sh" +# Release public key for the updater's independent signature re-verify (defense-in-depth over the app check). +PUBKEY_SRC="$UNIT_SRC/dispatch-update-public.pem" +[[ -f "$PUBKEY_SRC" ]] || PUBKEY_SRC="$SOURCE_DIR/src/Dispatch.Core/Updates/dispatch-update-public.pem" +install -m 644 "$PUBKEY_SRC" "$INSTALL_DIR/dispatch-update-public.pem" +# Mark this install self-managed so the dashboard exposes the "upload upgrade package" flow. +mkdir -p "$DATA_DIR/updates"; touch "$DATA_DIR/updates/.self-managed" +chown -R dispatch:dispatch "$DATA_DIR/updates" + +systemctl daemon-reload +if [[ "$NO_START" == "1" ]]; then + systemctl enable dispatch dispatch-update.path # appliance build: started on first boot, not now + echo "==> Service + updater enabled (not started - --no-start)" +else + systemctl enable --now dispatch dispatch-update.path +fi + +# Open firewall ports if a supported firewall is active (best-effort). +if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + for p in "$HTTP_PORT" "$API_PORT" ${SMTP_PORTS//,/ }; do ufw allow "$p"/tcp >/dev/null 2>&1 || true; done +elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then + for p in "$HTTP_PORT" "$API_PORT" ${SMTP_PORTS//,/ }; do firewall-cmd --permanent --add-port="$p"/tcp >/dev/null 2>&1 || true; done + firewall-cmd --reload >/dev/null 2>&1 || true +fi + +# The dashboard is always HTTPS (a self-signed cert is generated when no TLS cert is configured). +SCHEME="https" +if [[ "$NO_START" == "1" ]]; then + echo + echo "Dispatch is staged (service enabled, not started) - it will start on first boot." + exit 0 +fi +echo +echo "Dispatch is installed and running." +echo " Dashboard: ${SCHEME}://localhost:$HTTP_PORT (log in with the admin password you set)" +echo " Ports, retention and other settings are managed in the dashboard (default SMTP port 2525)." +echo " Status: systemctl status dispatch" +echo " Logs: journalctl -u dispatch -f (and $LOG_DIR)" diff --git a/installer/windows/Dispatch.wxs b/installer/windows/Dispatch.wxs new file mode 100644 index 00000000..67d5ebd0 --- /dev/null +++ b/installer/windows/Dispatch.wxs @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/windows/WriteAppSettings.js b/installer/windows/WriteAppSettings.js new file mode 100644 index 00000000..5417bbcd --- /dev/null +++ b/installer/windows/WriteAppSettings.js @@ -0,0 +1,54 @@ +// Deferred custom action (adapted from the FluxDeploy installer pattern): writes appsettings.json into +// the Dispatch data directory. Per spec §12.1, appsettings holds ONLY the SQL connection string and the +// Web UI TLS cert path - everything else lives in the SQL config table. The admin password is set on first +// run via the dashboard, so it is NOT written here. +// CustomActionData format: "sqlConn|dataDir" +var data = Session.Property("CustomActionData"); +var parts = data.split("|"); +if (parts.length >= 2) { + var sqlConn = parts[0].replace(/^\s+|\s+$/g, ""); + var dataDir = parts[1].replace(/^\s+|\s+$/g, "").replace(/\\+$/, ""); + + var fso = new ActiveXObject("Scripting.FileSystemObject"); + if (!fso.FolderExists(dataDir)) { fso.CreateFolder(dataDir); } + + // Mark this install self-managed so the dashboard exposes the "upload upgrade package" flow. + var updatesDir = fso.BuildPath(dataDir, "updates"); + if (!fso.FolderExists(updatesDir)) { fso.CreateFolder(updatesDir); } + var marker = fso.BuildPath(updatesDir, ".self-managed"); + if (!fso.FileExists(marker)) { fso.CreateTextFile(marker, true).Close(); } + + var targetPath = fso.BuildPath(dataDir, "appsettings.json"); + // Don't clobber an existing config (preserves manual edits + upgrades). + if (!fso.FileExists(targetPath)) { + // JSON-escape the connection string (backslashes for the .\INSTANCE form, quotes). + var conn = sqlConn.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + var json = "{\r\n" + + ' "ConnectionStrings": {\r\n' + + ' "DispatchLog": "' + conn + '"\r\n' + + " },\r\n" + + ' "WebUi": {\r\n' + + ' "TlsCertPath": "",\r\n' + + ' "TlsCertPassword": ""\r\n' + + " },\r\n" + + ' "Logging": {\r\n' + + ' "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }\r\n' + + " }\r\n" + + "}"; + var stream = fso.CreateTextFile(targetPath, true); + stream.Write(json); + stream.Close(); + } + + // Lock down the data directory so the connection string, spool (message bodies), the .dispatch-key + // encryption key, and logs aren't readable by other local users (ProgramData is world-readable by + // default). Approach that can NEVER lock out the service: convert inherited ACEs to explicit + // (/inheritance:d), then remove ONLY BUILTIN\Users (S-1-5-32-545) and Authenticated Users (S-1-5-11). + // SYSTEM + Administrators (the service runs as LocalSystem) are left untouched, so the service keeps + // access. Best-effort: never fail the install. + try { + var shell = new ActiveXObject("WScript.Shell"); + shell.Run('cmd /c icacls "' + dataDir + '" /inheritance:d /T /C', 0, true); + shell.Run('cmd /c icacls "' + dataDir + '" /remove:g "*S-1-5-32-545" "*S-1-5-11" /T /C', 0, true); + } catch (e) { /* ignore - the per-file key ACL still applies */ } +} diff --git a/installer/windows/bundle/Bundle.wxs b/installer/windows/bundle/Bundle.wxs new file mode 100644 index 00000000..9278c9cc --- /dev/null +++ b/installer/windows/bundle/Bundle.wxs @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/installer/windows/functional-smoke.ps1 b/installer/windows/functional-smoke.ps1 new file mode 100644 index 00000000..3ca7ac9e --- /dev/null +++ b/installer/windows/functional-smoke.ps1 @@ -0,0 +1,406 @@ +#Requires -Version 7 +<# + Functional QC smoke for a real Dispatch install - the pre-release gate that proves core functionality + actually works end to end (not just that the service starts). Uses the Local provider so nothing leaves + the box and delivery is deterministic; delivery is confirmed via the /stats delivered counter (the log + stores spool ids in a different format than the API returns, so a counter delta is the robust signal). + + Coverage (each section cleans up the resources it creates): + + 1. API delivery - POST /api/v1/messages (HTTP, port 8025) -> Delivered via the Local relay + 2. SMTP delivery - a real SMTP session upgraded with STARTTLS -> Delivered + 3. API key revocation - a revoked key is refused (401) + 4. SMTP AUTH - over STARTTLS: valid creds accepted (235), bad creds rejected (535) + 5. Local Inbox + features - API message with cc/html/tags is captured; detail shows cc + bodies + 6. Attachments (SMTP) - a MIME attachment sent over SMTP is parsed and downloadable from the inbox + 6b. Attachments (HTTP API) - a Mailgun-style multipart 'attachment' upload is parsed and downloadable + 7. Routing rules + simulate - a recipient rule routes to the chosen relay; non-match falls to default + 8. Relay test endpoint - POST /relays/{id}/test succeeds against the Local provider + 9. Reports round-trip - /reports reflects the deliveries we just made + 10. Config round-trip - PUT /config/api persists + reloads the cache (live setting) + 11. Settings round-trip - PUT /settings persists a retention threshold + 12. Audit log - operations produce audit rows + 13. Retention purges - real data: backdated files are deleted at 1-day retention and KEPT at + 0 (0 = keep forever); log/audit purges honour 0 too; recorded in history + 14. Storage usage - /storage breakdown reflects real rows/files (per-event + spool dirs) + 15. Read-only endpoints - health/stats/system/spool/metrics all answer + 16. Size-pressure archive - forces Express size-pressure; oldest rows exported to weekly JSONL + before deletion (destructive, runs last) + + Run after the service is up (serving /health). +#> +[CmdletBinding()] +param( + [string]$Dashboard = 'https://localhost:8420', + [string]$Api = 'http://localhost:8025', + [string]$Password = 'Zq7-Marsh-Pylon-Vex!', + [string]$DataRoot = "$env:ProgramData\Dispatch" # content root; relative spool/key paths resolve here +) +$ErrorActionPreference = 'Stop' +$hdr = @{ 'X-Dispatch-Request' = '1' } +$sess = New-Object Microsoft.PowerShell.Commands.WebRequestSession +$tok = (New-Guid).Guid.Substring(0, 8) # run-unique token so subjects don't collide across runs + +function DGet ($path) { Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Uri "$Dashboard$path" } +function DPost ($path,$body) { Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Headers $hdr -ContentType 'application/json' -Method Post -Uri "$Dashboard$path" -Body $body } +function DPut ($path,$body) { Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Headers $hdr -ContentType 'application/json' -Method Put -Uri "$Dashboard$path" -Body $body } +function DDel ($path) { Invoke-RestMethod -SkipCertificateCheck -WebSession $sess -Headers $hdr -Method Delete -Uri "$Dashboard$path" } +function Delivered { [int]((DGet '/api/stats').delivered) } + +function Wait-Delivered([int]$baseline, [int]$timeoutSec = 30) { + $deadline = (Get-Date).AddSeconds($timeoutSec) + while ((Get-Date) -lt $deadline) { + if ((Delivered) -gt $baseline) { return } + Start-Sleep -Milliseconds 700 + } + throw "delivered count did not increase from $baseline within ${timeoutSec}s" +} + +# Opens an SMTP session and upgrades it with STARTTLS (accepting the self-signed cert), returning readers +# over the TLS stream ready for MAIL FROM / AUTH. Throws if STARTTLS is refused. +function New-StartTlsSmtp([int]$port) { + $tcp = New-Object System.Net.Sockets.TcpClient + $tcp.Connect('127.0.0.1', $port) + $raw = $tcp.GetStream(); $raw.ReadTimeout = 8000; $raw.WriteTimeout = 8000 + $r = New-Object System.IO.StreamReader($raw) + $w = New-Object System.IO.StreamWriter($raw); $w.NewLine = "`r`n"; $w.AutoFlush = $true + [void]$r.ReadLine() # 220 banner + $w.WriteLine('EHLO smoke.ci'); do { $l = $r.ReadLine() } while ($l -match '^250-') + $w.WriteLine('STARTTLS'); $resp = $r.ReadLine() + if ($resp -notmatch '^220') { throw "STARTTLS refused: $resp" } + $ssl = New-Object System.Net.Security.SslStream($raw, $false, ([System.Net.Security.RemoteCertificateValidationCallback] { $true })) + $ssl.AuthenticateAsClient('localhost') + $sr = New-Object System.IO.StreamReader($ssl) + $sw = New-Object System.IO.StreamWriter($ssl); $sw.NewLine = "`r`n"; $sw.AutoFlush = $true + $sw.WriteLine('EHLO smoke.ci'); do { $l = $sr.ReadLine() } while ($l -match '^250-') # caps over TLS + return [pscustomobject]@{ Tcp = $tcp; R = $sr; W = $sw } +} + +# Runs a full MAIL/RCPT/DATA exchange over an open (post-STARTTLS) connection. $dataLines are the raw +# message lines (headers + blank line + body); the trailing "." is appended here. +function Send-SmtpMessage($c, [string]$from, [string]$to, [string[]]$dataLines) { + $c.W.WriteLine("MAIL FROM:<$from>"); $m = $c.R.ReadLine(); if ($m -notmatch '^250') { throw "MAIL FROM: $m" } + $c.W.WriteLine("RCPT TO:<$to>"); $m = $c.R.ReadLine(); if ($m -notmatch '^250') { throw "RCPT TO: $m" } + $c.W.WriteLine('DATA'); $m = $c.R.ReadLine(); if ($m -notmatch '^3') { throw "DATA: $m" } + foreach ($line in $dataLines) { $c.W.WriteLine($line) } + $c.W.WriteLine('.'); $m = $c.R.ReadLine(); if ($m -notmatch '^250') { throw "end-of-DATA: $m" } +} + +# Returns an Invoke-WebRequest body as text whether PowerShell decoded it as a string (text content +# types, e.g. text/plain) or left it as raw bytes (binary content types, e.g. application/octet-stream). +function Get-BodyText($resp) { + if ($resp.Content -is [byte[]]) { return [Text.Encoding]::ASCII.GetString($resp.Content) } + return [string]$resp.Content +} + +# Polls the Local Inbox for a captured message with the given subject and returns its id (.eml name). +function Find-LocalMessageId([string]$subject, [int]$timeoutSec = 15) { + $deadline = (Get-Date).AddSeconds($timeoutSec) + while ((Get-Date) -lt $deadline) { + $hit = (DGet '/api/local/messages').items | Where-Object { $_.subject -eq $subject } | Select-Object -First 1 + if ($hit) { return $hit.id } + Start-Sleep -Milliseconds 500 + } + throw "local message '$subject' not captured within ${timeoutSec}s" +} + +# --- auth: first-run set password, otherwise log in ---------------------------------------------- +$status = DGet '/api/auth/status' +$pwBody = "{""password"":""$Password""}" +if ($status.needsSetup) { DPost '/api/auth/password' $pwBody | Out-Null; Write-Host 'auth: first-run password set' } +else { DPost '/api/auth/login' $pwBody | Out-Null; Write-Host 'auth: logged in' } + +# --- ensure a Local default relay (captures locally, logs Delivered; no external delivery) -------- +$relay = DPost '/api/relays' '{"name":"smoke-local","provider":"Local"}' +DPost "/api/relays/$($relay.id)/set-default" '{}' | Out-Null +Write-Host "relay: Local relay $($relay.id) is now the default" + +# --- discover the bound SMTP port ---------------------------------------------------------------- +$health = Invoke-RestMethod -SkipCertificateCheck -Uri "$Dashboard/health" +$smtpPort = 25 +if ($health.smtp.listeningPorts) { $smtpPort = [int]$health.smtp.listeningPorts[0] } +elseif ($health.smtp.ports) { $smtpPort = [int]$health.smtp.ports[0] } +Write-Host "smtp port: $smtpPort" + +# === 1. API delivery ============================================================================ +$key = DPost '/api/keys' '{"name":"smoke-api","rateLimitPerMinute":0}' +$base = Delivered +$send = Invoke-RestMethod -Method Post -Uri "$Api/api/v1/messages" -Headers @{ Authorization = "Bearer $($key.key)" } ` + -ContentType 'application/json' -Body '{"from":"smoke-api@local.test","to":["dest@local.test"],"subject":"smoke-api","text":"hi"}' +if (-not $send.id) { throw "API send did not return a spool id" } +Wait-Delivered $base +Write-Host "OK: API message delivered ($($send.id))" + +# === 2. SMTP delivery over STARTTLS ============================================================= +$base = Delivered +$c = New-StartTlsSmtp $smtpPort +Send-SmtpMessage $c 'smoke-smtp@local.test' 'dest@local.test' @('Subject: smoke-smtp', '', 'hello over starttls') +$c.W.WriteLine('QUIT'); $c.Tcp.Close() +Wait-Delivered $base +Write-Host "OK: SMTP message delivered over STARTTLS" + +# === 3. API key revocation ====================================================================== +Invoke-RestMethod -Method Post -Uri "$Api/api/v1/messages" -Headers @{ Authorization = "Bearer $($key.key)" } ` + -ContentType 'application/json' -Body '{"from":"smoke-api@local.test","to":["dest@local.test"],"subject":"pre-revoke","text":"hi"}' | Out-Null +DDel "/api/keys/$($key.id)" | Out-Null +Start-Sleep -Seconds 1 +$code = 0 +try { + Invoke-RestMethod -Method Post -Uri "$Api/api/v1/messages" -Headers @{ Authorization = "Bearer $($key.key)" } ` + -ContentType 'application/json' -Body '{"from":"smoke-api@local.test","to":["dest@local.test"],"subject":"post-revoke","text":"hi"}' | Out-Null +} +catch { $code = [int]$_.Exception.Response.StatusCode } +if ($code -ne 401 -and $code -ne 403) { throw "a revoked API key must be refused (401/403), got $code" } +Write-Host "OK: revoked API key refused ($code)" + +# === 4. SMTP AUTH over STARTTLS ================================================================= +$u = 'smoke-auth'; $cp = 'Zx9-Auth-Smoke-7q' +DPost '/api/smtp-credentials' "{""username"":""$u"",""password"":""$cp""}" | Out-Null +try { + $c = New-StartTlsSmtp $smtpPort + $ok = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("`0$u`0$cp")) + $c.W.WriteLine("AUTH PLAIN $ok"); $m = $c.R.ReadLine(); $c.Tcp.Close() + if ($m -notmatch '^235') { throw "valid AUTH should return 235, got: $m" } + Write-Host "OK: SMTP AUTH over STARTTLS accepted valid credentials (235)" + + $c = New-StartTlsSmtp $smtpPort + $bad = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("`0$u`0wrong-password")) + $c.W.WriteLine("AUTH PLAIN $bad"); $m = $c.R.ReadLine(); $c.Tcp.Close() + if ($m -notmatch '^535') { throw "bad AUTH should return 535, got: $m" } + Write-Host "OK: SMTP AUTH over STARTTLS rejected bad credentials (535)" +} +finally { DDel "/api/smtp-credentials/$u" | Out-Null } + +# === 5. Local Inbox + message features (cc / html / tags via the HTTP API) ======================= +DDel '/api/local/messages' | Out-Null # start from a clean inbox for a precise assertion +$key2 = DPost '/api/keys' '{"name":"smoke-features","rateLimitPerMinute":0}' +$subj = "smoke-features-$tok" +$base = Delivered +$body = @{ + from = 'features@local.test'; to = @('dest@local.test'); cc = @('carbon@local.test') + subject = $subj; text = 'plain part'; html = '

html part

' + headers = @{ 'X-Smoke-Tag' = $tok }; tags = @('smoke', 'features') +} | ConvertTo-Json -Depth 5 +Invoke-RestMethod -Method Post -Uri "$Api/api/v1/messages" -Headers @{ Authorization = "Bearer $($key2.key)" } ` + -ContentType 'application/json' -Body $body | Out-Null +Wait-Delivered $base +$id = Find-LocalMessageId $subj +$det = DGet "/api/local/messages/$id" +if ($det.cc -notmatch 'carbon@local.test') { throw "captured message is missing the Cc recipient: $($det.cc)" } +if ($det.html -notmatch 'html part') { throw "captured message is missing the HTML body" } +if ($det.text -notmatch 'plain part') { throw "captured message is missing the text body" } +DDel "/api/keys/$($key2.id)" | Out-Null +Write-Host "OK: Local Inbox captured a feature-rich message (cc + text + html)" + +# === 6. Attachments: send a MIME attachment over SMTP, parse + download it from the inbox ========= +$asubj = "smoke-attach-$tok" +$attText = "dispatch-smoke-attachment-$tok" +$attB64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($attText)) +$base = Delivered +$c = New-StartTlsSmtp $smtpPort +Send-SmtpMessage $c 'attach@local.test' 'dest@local.test' @( + "Subject: $asubj", 'MIME-Version: 1.0', 'Content-Type: multipart/mixed; boundary="bnd1"', '', + '--bnd1', 'Content-Type: text/plain; charset=utf-8', '', 'see attached', '', + '--bnd1', 'Content-Type: application/octet-stream; name="hello.txt"', + 'Content-Disposition: attachment; filename="hello.txt"', 'Content-Transfer-Encoding: base64', '', + $attB64, '', '--bnd1--') +$c.W.WriteLine('QUIT'); $c.Tcp.Close() +Wait-Delivered $base +$aid = Find-LocalMessageId $asubj +$adet = DGet "/api/local/messages/$aid" +if (@($adet.attachments).Count -lt 1) { throw "attachment was not parsed from the captured message" } +$dl = Invoke-WebRequest -SkipCertificateCheck -WebSession $sess -Uri "$Dashboard/api/local/messages/$aid/attachments/0" +if ((Get-BodyText $dl) -notmatch [regex]::Escape($attText)) { throw "downloaded attachment content did not match" } +Write-Host "OK: SMTP attachment parsed by the inbox and downloaded intact" + +# === 6b. Attachments over the HTTP API (Mailgun-style multipart 'attachment' field) ============== +$apiSubj = "smoke-attach-api-$tok" +$apiAttText = "dispatch-api-attachment-$tok" +$attFile = Join-Path ([IO.Path]::GetTempPath()) "smoke-attach-$tok.txt" +Set-Content -LiteralPath $attFile -Value $apiAttText -NoNewline -Encoding ascii +$key3 = DPost '/api/keys' '{"name":"smoke-attach-api","rateLimitPerMinute":0}' +$base = Delivered +try { + # -Form sends multipart/form-data; a FileInfo value becomes a file part. This is the exact Mailgun shape: + # `attachment` file field(s), `o:tag` tags, `h:` custom headers. + $form = @{ + from = 'attach-api@local.test'; to = 'dest@local.test'; subject = $apiSubj + text = 'see api attachment'; 'o:tag' = 'smoke'; attachment = Get-Item -LiteralPath $attFile + } + Invoke-RestMethod -Method Post -Uri "$Api/api/v1/messages" -Headers @{ Authorization = "Bearer $($key3.key)" } -Form $form | Out-Null + Wait-Delivered $base + $apiId = Find-LocalMessageId $apiSubj + $apiDet = DGet "/api/local/messages/$apiId" + if (@($apiDet.attachments).Count -lt 1) { throw "API multipart attachment was not parsed into the message" } + $apiDl = Invoke-WebRequest -SkipCertificateCheck -WebSession $sess -Uri "$Dashboard/api/local/messages/$apiId/attachments/0" + if ((Get-BodyText $apiDl) -notmatch [regex]::Escape($apiAttText)) { throw "downloaded API attachment content did not match" } + Write-Host "OK: HTTP API accepted a multipart attachment (Mailgun-style) - parsed + downloaded intact" +} +finally { + DDel "/api/keys/$($key3.id)" | Out-Null + Remove-Item -LiteralPath $attFile -ErrorAction SilentlyContinue +} + +# === 7. Routing rules + simulate ================================================================ +# Routing rules match on the recipient DOMAIN (exact / *.domain / *), so the rule + simulate use a +# unique domain rather than a local-part pattern. +$routeDomain = "route-$tok.test" +$r2 = DPost '/api/relays' '{"name":"smoke-route","provider":"Local"}' +$rule = DPost '/api/routing/rules' "{""name"":""smoke-rule-$tok"",""recipientPattern"":""$routeDomain"",""relayId"":$($r2.id),""priority"":100}" +try { + $hit = DPost '/api/routing/simulate' "{""from"":""s@local.test"",""to"":""user@$routeDomain""}" + if ([int]$hit.relayId -ne [int]$r2.id) { throw "rule should route to relay $($r2.id), simulate chose $($hit.relayId)" } + if (-not $hit.matched) { throw "simulate should report matched=true for the rule recipient" } + $miss = DPost '/api/routing/simulate' '{"from":"s@local.test","to":"someone-else@unmatched.test"}' + if ([int]$miss.relayId -ne [int]$relay.id) { throw "non-matching recipient should fall to the default relay $($relay.id), got $($miss.relayId)" } + Write-Host "OK: routing rule matched in simulate; non-match fell through to the default relay" +} +finally { + DDel "/api/routing/rules/$($rule.id)" | Out-Null + DDel "/api/relays/$($r2.id)" | Out-Null +} + +# === 8. Relay test endpoint (Local provider) ==================================================== +$test = DPost "/api/relays/$($relay.id)/test" '{"to":"relay-test@local.test"}' +if (-not $test.ok) { throw "relay test against the Local provider should succeed: $($test | ConvertTo-Json -Compress)" } +Write-Host "OK: relay test endpoint succeeded against the Local provider" + +# === 9. Reports round-trip ====================================================================== +$rep = DGet '/api/reports' +if ([int]$rep.summary.received -lt 1) { throw "reports should show received >= 1, got $($rep.summary.received)" } +if ([int]$rep.summary.delivered -lt 1) { throw "reports should show delivered >= 1, got $($rep.summary.delivered)" } +Write-Host "OK: reports reflect deliveries (received=$($rep.summary.received), delivered=$($rep.summary.delivered))" + +# === 10. Config round-trip (live-applied: API rate limit persists + cache reloads) =============== +$origRl = [int](DGet '/api/config').api.rateLimitPerKey +$newRl = $origRl + 17 +DPut '/api/config/api' "{""rateLimitPerKey"":$newRl}" | Out-Null +$readRl = [int](DGet '/api/config').api.rateLimitPerKey +DPut '/api/config/api' "{""rateLimitPerKey"":$origRl}" | Out-Null # restore +if ($readRl -ne $newRl) { throw "config change did not persist/reload: expected $newRl, read $readRl" } +Write-Host "OK: config round-trip persisted and reloaded (rateLimitPerKey $origRl -> $newRl -> restored)" + +# === 11. Settings round-trip (retention threshold persists) ===================================== +$origDays = [int](DGet '/api/settings').retention.logDeliveredRetentionDays +$newDays = $origDays + 1 +DPut '/api/settings' "{""retention"":{""logDeliveredRetentionDays"":$newDays}}" | Out-Null +$readDays = [int](DGet '/api/settings').retention.logDeliveredRetentionDays +DPut '/api/settings' "{""retention"":{""logDeliveredRetentionDays"":$origDays}}" | Out-Null # restore +if ($readDays -ne $newDays) { throw "settings change did not persist: expected $newDays, read $readDays" } +Write-Host "OK: settings round-trip persisted (logDeliveredRetentionDays $origDays -> $newDays -> restored)" + +# === 12. Audit log (login + config changes produced rows; also exercise the category filter) ======= +$audit = DGet '/api/audit' +if (@($audit.rows).Count -lt 1) { throw "audit log should contain rows after login + config changes" } +$cfgAudit = DGet '/api/audit?category=Config' # config PUTs above audit with category "Config" +if (@($cfgAudit.rows).Count -lt 1) { throw "audit log should contain Config-category rows after config changes" } +Write-Host "OK: audit log recorded operations ($(@($audit.rows).Count) rows, $(@($cfgAudit.rows).Count) Config)" + +# === 13. Retention purges actually delete - and 0 means "keep forever" (real data, live install) == +# Verified against real spooled files on the box: backdate a file so a 1-day retention removes it, and +# confirm 0 keeps it (the industry "0 = keep forever" convention). The purge worker reads retention via +# a 10-second cache, so each change waits the TTL out before the dependent purge. Settings are restored +# afterwards so the install is never left with weakened retention. + +# PUT a retention change, wait out the purge-settings cache, then run a manual purge. +function Invoke-PurgeAfter($retentionJson) { + DPut '/api/settings' $retentionJson | Out-Null + Start-Sleep -Seconds 12 + DPost '/api/purge/run' '{}' | Out-Null +} + +# Writes a dummy .eml into $dir and backdates it 10 days so a >=1-day retention will purge it. +function New-AgedEml([string]$dir, [string]$name) { + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $p = Join-Path $dir $name + Set-Content -LiteralPath $p -Value "From: aged@local.test`r`nSubject: aged`r`n`r`nbody" -Encoding ascii + (Get-Item -LiteralPath $p).LastWriteTimeUtc = (Get-Date).ToUniversalTime().AddDays(-10) + return $p +} + +$origRet = (DGet '/api/settings').retention +$spoolDir = (DGet '/api/config').spool.directory +# The service stores a relative spool dir (e.g. ./.dispatch-spool) resolved against its content root +# (the ProgramData data dir), so make it absolute before touching the filesystem. +if ($spoolDir -and -not [System.IO.Path]::IsPathRooted($spoolDir)) { + $spoolDir = Join-Path $DataRoot ($spoolDir -replace '^\.[\\/]', '') +} +if ($spoolDir -and (Test-Path -LiteralPath $spoolDir)) { + # 13a. Captured / Local Inbox file purge - 0 keeps, 1 deletes the aged file. + $capEml = New-AgedEml (Join-Path $spoolDir 'captured') "smoke-purge-cap-$tok.eml" + Invoke-PurgeAfter '{"retention":{"capturedRetentionDays":0}}' + if (-not (Test-Path -LiteralPath $capEml)) { throw "capturedRetentionDays=0 must KEEP files (0 = keep forever), but the aged file was deleted" } + Invoke-PurgeAfter '{"retention":{"capturedRetentionDays":1}}' + if (Test-Path -LiteralPath $capEml) { Remove-Item -LiteralPath $capEml -Force -EA SilentlyContinue; throw "capturedRetentionDays=1 should have deleted the 10-day-old captured file" } + Write-Host "OK: captured purge - 0 keeps the aged file, 1 deletes it" + + # 13b. Spool failed-file purge - same 0-keeps / 1-deletes proof on real spool files. + $failEml = New-AgedEml (Join-Path $spoolDir 'failed') "smoke-purge-fail-$tok.eml" + Invoke-PurgeAfter '{"retention":{"spoolFailedRetentionDays":0}}' + if (-not (Test-Path -LiteralPath $failEml)) { throw "spoolFailedRetentionDays=0 must KEEP files, but the aged file was deleted" } + Invoke-PurgeAfter '{"retention":{"spoolFailedRetentionDays":1}}' + if (Test-Path -LiteralPath $failEml) { Remove-Item -LiteralPath $failEml -Force -EA SilentlyContinue; throw "spoolFailedRetentionDays=1 should have deleted the 10-day-old failed file" } + Write-Host "OK: spool failed-file purge - 0 keeps the aged file, 1 deletes it" +} +else { Write-Host "SKIP: spool dir not locally accessible ($spoolDir) - file-purge deletion not exercised" } + +# 13c. Log-row + audit purge honour 0 = keep forever. (A fresh install has no >1-day-old rows to +# age-delete, so we prove the guard the user emphasized: retention 0 must NOT delete current data.) +$delBefore = [int](DGet '/api/messages?event=Delivered&pageSize=1').total +if ($delBefore -lt 1) { throw "expected Delivered log rows from the deliveries above" } +Invoke-PurgeAfter '{"retention":{"logDeliveredRetentionDays":0,"auditRetentionDays":0,"auditSecurityRetentionDays":0}}' +$delAfter = [int](DGet '/api/messages?event=Delivered&pageSize=1').total +if ($delAfter -lt $delBefore) { throw "logDeliveredRetentionDays=0 must keep all rows (0 = keep forever), but Delivered rows dropped ($delBefore -> $delAfter)" } +Write-Host "OK: log + audit purge honour 0 = keep forever (Delivered rows held at $delAfter)" + +# restore the original retention thresholds so the install isn't left with weakened retention. +$restore = @{ retention = @{ + capturedRetentionDays = [int]$origRet.capturedRetentionDays + spoolFailedRetentionDays = [int]$origRet.spoolFailedRetentionDays + logDeliveredRetentionDays = [int]$origRet.logDeliveredRetentionDays + auditRetentionDays = [int]$origRet.auditRetentionDays + auditSecurityRetentionDays = [int]$origRet.auditSecurityRetentionDays +} } | ConvertTo-Json -Depth 5 +DPut '/api/settings' $restore | Out-Null +if (@(DGet '/api/purge/history').Count -lt 1) { throw "a manual purge should appear in purge history" } +Write-Host "OK: purges recorded in history; retention thresholds restored" + +# === 14. Storage usage breakdown reflects real data ============================================= +$st = DGet '/api/storage' +if (-not $st.database.connected) { throw "storage: database should report connected" } +$deliveredUse = $st.database.relayLog.byEvent | Where-Object { $_.event -eq 'Delivered' } | Select-Object -First 1 +if (-not $deliveredUse -or [int]$deliveredUse.rows -lt 1) { throw "storage: expected Delivered rows in the message-log breakdown" } +if ([long]$st.database.relayLog.tableBytes -le 0) { throw "storage: relay_log table size should be > 0" } +if ($null -eq $st.spool.captured.files) { throw "storage: spool.captured.files missing" } +if ($null -eq $st.spool.failed.bytes) { throw "storage: spool.failed.bytes missing" } +Write-Host "OK: storage usage - Delivered rows=$($deliveredUse.rows), relay_log=$([math]::Round($st.database.relayLog.tableBytes/1KB))KB, captured files=$($st.spool.captured.files)" + +# === 15. Read-only / observability endpoints all answer ========================================= +foreach ($p in '/api/stats', '/api/stats/relays', '/api/stats/throughput', '/api/system', '/api/spool', '/health') { + DGet $p | Out-Null +} +$metrics = Invoke-WebRequest -SkipCertificateCheck -WebSession $sess -Uri "$Dashboard/metrics" +if ([int]$metrics.StatusCode -ne 200) { throw "/metrics did not return 200 (got $($metrics.StatusCode))" } +Write-Host "OK: stats / system / spool / health / metrics all answer" + +# === 16. Size-pressure ARCHIVES then deletes (Express only) - DESTRUCTIVE, runs last ============ +# Force size-pressure by dropping the trigger below current usage, then confirm the oldest rows are +# exported to weekly JSONL *before* being deleted (the emergency purge never silently loses history). +# This wipes the message-log/audit on the CI box, so it is intentionally the final step. +if ($spoolDir -and (Test-Path -LiteralPath $spoolDir)) { + $archiveDir = Join-Path $spoolDir 'archive' + Get-ChildItem -LiteralPath $archiveDir -Filter *.jsonl -EA SilentlyContinue | Remove-Item -Force -EA SilentlyContinue + $rowsBefore = [int](DGet '/api/messages?event=Delivered&pageSize=1').total + Invoke-PurgeAfter '{"retention":{"sizeTriggerGb":0.001,"sizeTargetGb":0.0005}}' # ~1 MB trigger -> always over + Start-Sleep -Seconds 2 + $archives = @(Get-ChildItem -LiteralPath $archiveDir -Filter 'relay_log-*.jsonl' -EA SilentlyContinue) + if ($archives.Count -lt 1) { throw "size-pressure should have written a relay_log JSONL archive before deleting" } + if ((Get-Content -LiteralPath $archives[0].FullName -TotalCount 1) -notmatch '"event"') { throw "archived JSONL row should carry the row fields" } + $rowsAfter = [int](DGet '/api/messages?event=Delivered&pageSize=1').total + if ($rowsAfter -ge $rowsBefore) { throw "size-pressure should have deleted message-log rows ($rowsBefore -> $rowsAfter)" } + DPut '/api/settings' '{"retention":{"sizeTriggerGb":9.5,"sizeTargetGb":9.0}}' | Out-Null # restore + Write-Host "OK: size-pressure archived $($archives.Count) JSONL file(s) then deleted rows ($rowsBefore -> $rowsAfter)" +} +else { Write-Host "SKIP: spool dir not locally accessible - size-pressure archive test not exercised" } + +Write-Host "FUNCTIONAL SMOKE PASSED" diff --git a/installer/windows/install.ps1 b/installer/windows/install.ps1 new file mode 100644 index 00000000..90bd8a77 --- /dev/null +++ b/installer/windows/install.ps1 @@ -0,0 +1,114 @@ +<# +.SYNOPSIS + Dispatch SMTP Relay - Windows installer (scripted). + +.DESCRIPTION + Publishes the service, writes config to %ProgramData%\Dispatch, registers a Windows Service, and + opens the firewall. The SQL connection string and the dashboard admin password are supplied at + install time (the admin password is required - you'll be prompted if it isn't passed). + + Run from an elevated PowerShell: + .\install.ps1 -SqlConnection "Server=...;Database=DispatchLog;User Id=sa;Password=...;TrustServerCertificate=True;Encrypt=True" + + NOTE: This script has not been executed on the build machine (macOS). It is the Windows install path + and should be validated on Windows. A WiX MSI (Dispatch.wxs) is also provided for packaged installs. +#> +param( + [Parameter(Mandatory = $true)][string]$SqlConnection, + [string]$AdminPassword, + [int]$HttpPort = 8420, + [int]$ApiPort = 8421, + [string]$SmtpPorts = "25,587", + [string]$Source +) + +$ErrorActionPreference = "Stop" +$InstallDir = "$Env:ProgramFiles\Dispatch" +$DataDir = "$Env:ProgramData\Dispatch" + +if (-not $AdminPassword) { + $secure = Read-Host -AsSecureString "Set the dashboard admin password" + $AdminPassword = [System.Net.NetworkCredential]::new("", $secure).Password + if (-not $AdminPassword) { throw "Admin password is required." } +} + +if (-not $Source) { $Source = (Resolve-Path "$PSScriptRoot\..\..").Path } + +# Require elevation: this installs a Windows service, writes to Program Files, sets ACLs, and opens firewall +# ports - all of which need Administrator. Fail fast with a clear message instead of a confusing mid-run error. +# (The DispatchSetup.exe bundle auto-elevates via UAC because its chain is per-machine; this script does not.) +$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)) { + Write-Error "Administrator rights are required. Re-run this from an elevated PowerShell (Run as administrator)." + exit 1 +} + +Write-Host "==> Building the web UI" +Push-Location "$Source\src\Dispatch.UI"; npm ci; npm run build; Pop-Location +Remove-Item -Recurse -Force "$Source\src\Dispatch.Web\wwwroot" -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force "$Source\src\Dispatch.Web\wwwroot" | Out-Null +Copy-Item -Recurse -Force "$Source\src\Dispatch.UI\dist\*" "$Source\src\Dispatch.Web\wwwroot\" + +Write-Host "==> Publishing the service to $InstallDir" +dotnet publish "$Source\src\Dispatch.Service" -c Release -o $InstallDir + +Write-Host "==> Writing config to $DataDir" +New-Item -ItemType Directory -Force "$DataDir\spool", "$DataDir\logs", "$DataDir\updates" | Out-Null +# Mark this install self-managed so the dashboard exposes the "upload upgrade package" flow (the service, +# running as LocalSystem, applies it by stopping itself, swapping binaries, and restarting via the SCM). +New-Item -ItemType File -Force "$DataDir\updates\.self-managed" | Out-Null +# Lock the data dir down: ProgramData is world-readable by default. Convert inherited ACEs to explicit, then +# remove ONLY BUILTIN\Users (S-1-5-32-545) and Authenticated Users (S-1-5-11) - leaving SYSTEM + Administrators +# (the service runs as LocalSystem) untouched so it can never be locked out. Protects the connection string, +# spool (message bodies), the .dispatch-key encryption key, and logs together. +& icacls "$DataDir" /inheritance:d /T /C | Out-Null +& icacls "$DataDir" /remove:g "*S-1-5-32-545" "*S-1-5-11" /T /C | Out-Null +$smtpJson = ($SmtpPorts -split ',' | ForEach-Object { $_.Trim() }) -join ', ' +$config = @{ + ConnectionStrings = @{ DispatchLog = $SqlConnection } + AdminPassword = $AdminPassword + Spool = @{ Directory = "$DataDir\spool"; WorkerCount = 4 } + Listener = @{ Ports = @($SmtpPorts -split ',' | ForEach-Object { [int]$_.Trim() }); AllowedCidrs = @("127.0.0.1/32", "::1/128") } + Api = @{ Port = $ApiPort } + WebUi = @{ Port = $HttpPort } +} +$config | ConvertTo-Json -Depth 6 | Set-Content "$DataDir\appsettings.json" -Encoding UTF8 + +Write-Host "==> Registering the Windows service" +$bin = "`"$InstallDir\Dispatch.Service.exe`" --contentRoot `"$DataDir`"" +sc.exe stop Dispatch 2>$null | Out-Null +sc.exe delete Dispatch 2>$null | Out-Null +New-Service -Name Dispatch -BinaryPathName $bin -DisplayName "Dispatch SMTP Relay" -StartupType Automatic | Out-Null +# Logs live under the ProgramData data dir (the service CWD is system32). The encryption key defaults to the +# content root, which is this same data dir. The dir is ACL-locked above so neither is readable by other users. +$envBlock = [string[]]@("DISPATCH_LOG_DIR=$DataDir\logs", "DOTNET_ENVIRONMENT=Production") +New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Dispatch" -Name Environment -PropertyType MultiString -Value $envBlock -Force | Out-Null + +Write-Host "==> Opening firewall ports" +foreach ($p in @($HttpPort, $ApiPort) + ($SmtpPorts -split ',' | ForEach-Object { [int]$_.Trim() })) { + New-NetFirewallRule -DisplayName "Dispatch $p" -Direction Inbound -Action Allow -Protocol TCP -LocalPort $p -ErrorAction SilentlyContinue | Out-Null +} + +Start-Service Dispatch + +# Tell the user exactly where the dashboard lives - on this box and from another machine on the LAN. +# The IPv4 lookup is best-effort; if it fails we still print the localhost URL. +$lan = $null +try { + $lan = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and $_.PrefixOrigin -ne 'WellKnown' } | + Select-Object -First 1 -ExpandProperty IPAddress) +} catch { } +Write-Host "" +Write-Host "==========================================================================" +Write-Host " Dispatch SMTP Relay is installed and running." +Write-Host "" +Write-Host " Open the dashboard to finish setup (set/confirm the admin password):" +Write-Host " On this server: https://localhost:$HttpPort" +if ($lan) { + Write-Host " From another machine: https://${lan}:$HttpPort" +} +Write-Host "" +Write-Host " The dashboard uses a self-signed certificate, so your browser will warn" +Write-Host " once on first visit - that is expected; continue past it." +Write-Host "==========================================================================" diff --git a/installer/windows/sql-express/InstallSqlExpress.ps1 b/installer/windows/sql-express/InstallSqlExpress.ps1 new file mode 100644 index 00000000..45c4e300 --- /dev/null +++ b/installer/windows/sql-express/InstallSqlExpress.ps1 @@ -0,0 +1,109 @@ +$ErrorActionPreference = 'Stop' +# Dispatch SMTP Relay - SQL Server Express bootstrap (adapted from the FluxDeploy installer pattern). +# Downloads + silently installs SQL Server Express as a named instance, then creates the DispatchLog +# database and grants the service account (LocalSystem) sysadmin so the service can connect with +# Windows auth. Idempotent: skips install if the instance already exists. Invoked by the WiX bundle +# (via InstallSqlExpress.exe) before the Dispatch MSI, or run standalone. +$logFile = 'C:\Windows\Temp\Dispatch-SqlInstall.log' +$dbName = if ($args[0]) { $args[0] } else { 'DispatchLog' } +$instance = 'DISPATCHSQL' +$serviceName = "MSSQL`$$instance" + +function Log($msg) { "$(Get-Date -Format o) $msg" | Out-File -Append -FilePath $logFile -Encoding utf8 } + +Log '=== Dispatch SQL Server Express install started ===' +Log "Instance: $instance Database: $dbName User: $([Environment]::UserName)" + +if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { + Log 'SQL Server Express instance already installed' +} else { + $tempDir = 'C:\Windows\Temp' + $bootstrapper = Join-Path $tempDir 'SQLEXPR-SSEI.exe' + $mediaDir = Join-Path $tempDir 'DispatchSqlMedia' + $extractDir = Join-Path $tempDir 'DispatchSqlSetup' + + Log 'Downloading SQL Server Express bootstrapper...' + # fwlink 2216019 resolves to aka.ms/sql2025express (the current SQL Server 2025 Express SSEI bootstrapper). + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri 'https://go.microsoft.com/fwlink/?linkid=2216019' -OutFile $bootstrapper -UseBasicParsing + Log "Bootstrapper: $([math]::Round((Get-Item $bootstrapper).Length / 1MB, 1)) MB" + + Log 'Downloading SQL Server Express media...' + New-Item -Path $mediaDir -ItemType Directory -Force | Out-Null + $dlJob = Start-Process -FilePath $bootstrapper -ArgumentList '/ACTION=Download',"/MEDIAPATH=$mediaDir",'/MEDIATYPE=Core','/QUIET' -Wait -PassThru -WindowStyle Hidden + Log "Bootstrapper exit: $($dlJob.ExitCode)" + $mediaExe = Get-ChildItem $mediaDir -Filter 'SQLEXPR*.exe' -EA SilentlyContinue | Select-Object -First 1 + if (-not $mediaExe) { Log 'ERROR: media download failed'; exit 1 } + Log "Media: $($mediaExe.Name) ($([math]::Round($mediaExe.Length / 1MB)) MB)" + + Log 'Extracting...' + New-Item -Path $extractDir -ItemType Directory -Force | Out-Null + $p = Start-Process -FilePath $mediaExe.FullName -ArgumentList '/q',"/x:$extractDir" -Wait -PassThru -WindowStyle Hidden + Log "Extract exit: $($p.ExitCode)" + $setupExe = Get-ChildItem $extractDir -Filter 'SETUP.EXE' -Recurse -EA SilentlyContinue | Select-Object -First 1 + if (-not $setupExe) { Log 'ERROR: SETUP.EXE not found'; exit 1 } + + # SQL 2025 changed the extraction layout - make sure MediaInfo.xml is two levels above SETUP.EXE. + $expectedMediaInfo = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($setupExe.DirectoryName, '..', '..', 'MediaInfo.xml')) + if (-not (Test-Path $expectedMediaInfo)) { + $foundMediaInfo = Get-ChildItem $extractDir -Filter 'MediaInfo.xml' -Recurse -EA SilentlyContinue | Select-Object -First 1 + if ($foundMediaInfo) { Copy-Item $foundMediaInfo.FullName $expectedMediaInfo -Force; Log "Placed MediaInfo.xml at $expectedMediaInfo" } + else { Log "WARNING: MediaInfo.xml not found in $extractDir" } + } + + try { Remove-Item 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\170\ConfigurationState' -Recurse -Force -EA SilentlyContinue } catch {} + try { Remove-Item 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\160\ConfigurationState' -Recurse -Force -EA SilentlyContinue } catch {} + + Log 'Installing SQL Server Express...' + # Grant BUILTIN\ADMINISTRATORS and NT AUTHORITY\SYSTEM sysadmin so the Dispatch service (LocalSystem) + # can connect with Windows auth. TCP disabled - the service connects over shared memory locally. + $installArgs = '/Q /IACCEPTSQLSERVERLICENSETERMS /ACTION=Install /FEATURES=SQLENGINE ' + + "/INSTANCENAME=$instance " + + '/SQLSVCACCOUNT="NT AUTHORITY\SYSTEM" ' + + '/SQLSYSADMINACCOUNTS="BUILTIN\ADMINISTRATORS" "NT AUTHORITY\SYSTEM" ' + + '/TCPENABLED=0 /UPDATEENABLED=False /USEMICROSOFTUPDATE=False' + Log "Args: $installArgs" + $installProc = Start-Process -FilePath $setupExe.FullName -ArgumentList $installArgs -Wait -PassThru -WindowStyle Hidden + Log "SETUP.EXE exit code: $($installProc.ExitCode)" + if ($installProc.ExitCode -ne 0) { + Log "ERROR: SQL Server Express install failed ($($installProc.ExitCode))" + $summary = Get-ChildItem 'C:\Program Files\Microsoft SQL Server' -Filter 'Summary*.txt' -Recurse -EA SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($summary) { Get-Content $summary.FullName | Select-Object -First 20 | Out-File -Append $logFile -Encoding utf8 } + exit $installProc.ExitCode + } + + Remove-Item $bootstrapper -Force -EA SilentlyContinue + Remove-Item $mediaDir -Recurse -Force -EA SilentlyContinue + Remove-Item $extractDir -Recurse -Force -EA SilentlyContinue +} + +Log 'Waiting for SQL Server Express service...' +for ($i = 0; $i -lt 120; $i++) { + if (Get-Service -Name $serviceName -EA SilentlyContinue | Where-Object { $_.Status -eq 'Running' }) { Log 'SQL Express running'; break } + Start-Sleep -Seconds 5 +} + +Log "Creating database [$dbName] + ensuring NT AUTHORITY\SYSTEM login..." +for ($attempt = 1; $attempt -le 6; $attempt++) { + try { + $conn = New-Object System.Data.SqlClient.SqlConnection("Server=.\$instance;Database=master;Trusted_Connection=yes;") + $conn.Open() + $cmd = $conn.CreateCommand() + $cmd.CommandText = @" +IF DB_ID('$dbName') IS NULL CREATE DATABASE [$dbName]; +IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = 'NT AUTHORITY\SYSTEM') + CREATE LOGIN [NT AUTHORITY\SYSTEM] FROM WINDOWS; +EXEC sp_addsrvrolemember 'NT AUTHORITY\SYSTEM', 'sysadmin'; +"@ + $cmd.ExecuteNonQuery() | Out-Null + $conn.Close() + Log "Database [$dbName] ready" + break + } catch { + Log "DB attempt $attempt/6: $($_.Exception.Message)" + Start-Sleep -Seconds 10 + } +} + +Log '=== Dispatch SQL Server Express install complete ===' +exit 0 diff --git a/installer/windows/sql-express/SqlExpressLauncher.cs b/installer/windows/sql-express/SqlExpressLauncher.cs new file mode 100644 index 00000000..618afd0d --- /dev/null +++ b/installer/windows/sql-express/SqlExpressLauncher.cs @@ -0,0 +1,31 @@ +using System; +using System.Diagnostics; +using System.IO; + +// Thin launcher (adapted from FluxDeploy): a WiX Burn ExePackage runs an EXE, not a .ps1, so this +// wrapper invokes InstallSqlExpress.ps1 (shipped alongside it as a bundle payload). Arg 0 = DB name. +class Program +{ + static int Main(string[] args) + { + var scriptDir = AppDomain.CurrentDomain.BaseDirectory; + var scriptPath = Path.Combine(scriptDir, "InstallSqlExpress.ps1"); + if (!File.Exists(scriptPath)) + { + Console.Error.WriteLine($"Script not found: {scriptPath}"); + return 1; + } + + var dbName = args.Length > 0 ? args[0] : "DispatchLog"; + var psi = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{scriptPath}\" {dbName}", + UseShellExecute = false, + CreateNoWindow = true, + }; + var process = Process.Start(psi); + process.WaitForExit(); + return process.ExitCode; + } +} diff --git a/installer/windows/sql-express/SqlExpressLauncher.csproj b/installer/windows/sql-express/SqlExpressLauncher.csproj new file mode 100644 index 00000000..0249c362 --- /dev/null +++ b/installer/windows/sql-express/SqlExpressLauncher.csproj @@ -0,0 +1,14 @@ + + + Exe + net472 + InstallSqlExpress + x64 + + + + + + + diff --git a/src/Dispatch.Core/ApiKeys/ApiKey.cs b/src/Dispatch.Core/ApiKeys/ApiKey.cs new file mode 100644 index 00000000..0e0932f0 --- /dev/null +++ b/src/Dispatch.Core/ApiKeys/ApiKey.cs @@ -0,0 +1,19 @@ +namespace Dispatch.Core.ApiKeys; + +/// A row from the SQL api_keys table (spec §7.6). Never carries the plaintext key. +public sealed class ApiKey +{ + public int Id { get; init; } + public string KeyId { get; init; } = ""; // public prefix, e.g. dsp_live_a1b + public string KeyHash { get; init; } = ""; // bcrypt hash of the full key + public string Name { get; init; } = ""; + public DateTime CreatedAt { get; init; } + public DateTime? LastUsedAt { get; init; } + public long MessageCount { get; init; } + public bool Revoked { get; init; } + public DateTime? RevokedAt { get; init; } + public int RateLimitPerMinute { get; init; } +} + +/// Returned once on creation - the only time the plaintext key is available (spec §7.6). +public sealed record ApiKeyCreated(ApiKey Key, string PlaintextKey); diff --git a/src/Dispatch.Core/ApiKeys/IApiKeyRepository.cs b/src/Dispatch.Core/ApiKeys/IApiKeyRepository.cs new file mode 100644 index 00000000..645b2c01 --- /dev/null +++ b/src/Dispatch.Core/ApiKeys/IApiKeyRepository.cs @@ -0,0 +1,17 @@ +namespace Dispatch.Core.ApiKeys; + +/// Manages HTTP ingestion API keys (spec §7.6–§7.7, §17.4). +public interface IApiKeyRepository +{ + /// Generates a dsp_live_… key (256-bit), stores only its bcrypt hash, returns the plaintext once. + Task CreateAsync(string name, int rateLimitPerMinute, CancellationToken ct = default); + + Task> ListAsync(bool includeRevoked = false, CancellationToken ct = default); + + Task RevokeAsync(int id, CancellationToken ct = default); + + /// Looks up by key_id prefix then bcrypt-verifies. Constant-time even when not found (§17.4). + Task VerifyAsync(string rawKey, CancellationToken ct = default); + + Task RecordUsageAsync(int id, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Audit/AuditLog.cs b/src/Dispatch.Core/Audit/AuditLog.cs new file mode 100644 index 00000000..8be1e361 --- /dev/null +++ b/src/Dispatch.Core/Audit/AuditLog.cs @@ -0,0 +1,57 @@ +namespace Dispatch.Core.Audit; + +/// A single audit/security log entry shown on the System Logs page. +public sealed record AuditEntry( + long Id, DateTime LoggedAt, string Kind, string Category, string Event, + string Severity, string? Actor, string? SourceIp, string? Detail); + +/// Cursor for keyset "load more" (newest-first). +public sealed record AuditCursor(DateTime LoggedAt, long Id); + +/// Filters for the Logs query. Kind is null/"" for all, else "audit"|"relay"|"system"; +/// Category/Severity are optional exact-match column filters; Search is a free-text contains. +public sealed record AuditFilter(string? Kind, string? Category, string? Severity, string? Search, int Limit, AuditCursor? Cursor); + +public sealed record AuditPage(IReadOnlyList Rows, AuditCursor? NextCursor); + +/// +/// Append-only audit/security event log (spec §17). Writes are best-effort - a logging failure must never +/// break the action being audited, so implementations swallow errors. +/// +public interface IAuditLog +{ + Task WriteAsync(string kind, string category, string @event, string severity, + string? actor, string? sourceIp, string? detail, CancellationToken ct = default); + + Task QueryAsync(AuditFilter filter, CancellationToken ct = default); + + /// Retention purge: deletes audit entries older than , + /// and the noisier Access/SmtpAuth security events older than + /// (kept shorter). A retention of 0 means "keep forever" for that tier. Best-effort. Returns rows deleted. + Task PurgeAsync(int generalRetentionDays, int securityRetentionDays, CancellationToken ct = default); + + /// Size-pressure step (Express only): reads the oldest audit rows, hands + /// them to to persist, then deletes them. Returns rows deleted; nothing is + /// deleted if archiving throws. + Task ArchiveAndDeleteOldestAsync(int batch, Dispatch.Core.Maintenance.ArchiveRows archive, CancellationToken ct = default); +} + +/// Ergonomic helpers so call sites read clearly and use consistent kinds/severities. +public static class AuditLogExtensions +{ + public static Task Audit(this IAuditLog log, string category, string @event, + string severity = "Info", string? actor = null, string? sourceIp = null, string? detail = null, CancellationToken ct = default) + => log.WriteAsync("audit", category, @event, severity, actor, sourceIp, detail, ct); + + /// A system-level problem - e.g. an unhandled server exception. + public static Task System(this IAuditLog log, string @event, string? detail, string? sourceIp = null, CancellationToken ct = default) + => log.WriteAsync("system", "System", @event, "Error", actor: null, sourceIp, detail, ct); + + /// A normal system lifecycle event (startup, scheduled cleanup, disk-pressure change). + public static Task Lifecycle(this IAuditLog log, string @event, string? detail = null, string severity = "Info", CancellationToken ct = default) + => log.WriteAsync("system", "System", @event, severity, actor: null, sourceIp: null, detail, ct); + + /// A relay/delivery problem (e.g. a provider rejected the message - bad API key, etc.). + public static Task Relay(this IAuditLog log, string @event, string? detail, string severity = "Error", CancellationToken ct = default) + => log.WriteAsync("relay", "Relay", @event, severity, actor: null, sourceIp: null, detail, ct); +} diff --git a/src/Dispatch.Core/Configuration/ApiOptions.cs b/src/Dispatch.Core/Configuration/ApiOptions.cs new file mode 100644 index 00000000..30899dca --- /dev/null +++ b/src/Dispatch.Core/Configuration/ApiOptions.cs @@ -0,0 +1,34 @@ +namespace Dispatch.Core.Configuration; + +/// HTTP ingestion API settings (spec §7.2), bound from the "Api" config section. +public sealed class ApiOptions +{ + public const string SectionName = "Api"; + + public int Port { get; set; } = 8025; + + /// Whether the plain-HTTP listener is bound. Can be turned off to run HTTPS-only. + public bool HttpEnabled { get; set; } = true; + + /// Whether an additional HTTPS listener (on ) is bound, using the shared + /// TLS certificate (). + public bool TlsEnabled { get; set; } + public int TlsPort { get; set; } = 8026; + + /// Shared TLS cert (PFX) path + password - also used by the SMTP listener's STARTTLS. + public string TlsCertPath { get; set; } = ""; + public string TlsCertPassword { get; set; } = ""; + + /// Source-IP allow-list (closed model - see ): an empty list denies + /// all. The ingestion API is additionally gated by API keys. Operators manage ranges in Access Control. + public string[] AllowedCidrs { get; set; } = []; + public long MaxMessageBytes { get; set; } = 0; + public int RateLimitPerKey { get; set; } = 100; + + public string[] EffectiveAllowedCidrs => AllowedCidrs; + + /// True if is one of the API's bound ports (plain HTTP and/or + /// HTTPS), respecting which listeners are enabled. Gates the ingestion endpoints + auth middleware. + public bool IsApiPort(int localPort) => + (HttpEnabled && localPort == Port) || (TlsEnabled && localPort == TlsPort); +} diff --git a/src/Dispatch.Core/Configuration/ConfigCache.cs b/src/Dispatch.Core/Configuration/ConfigCache.cs new file mode 100644 index 00000000..cea3e176 --- /dev/null +++ b/src/Dispatch.Core/Configuration/ConfigCache.cs @@ -0,0 +1,102 @@ +using System.Globalization; +using System.Text.Json; + +namespace Dispatch.Core.Configuration; + +/// +/// In-memory snapshot of the SQL config table - the single source of truth for all runtime settings +/// (spec §12.5). Loaded once at startup with one query and refreshed () within the +/// same request that saves a setting, so changes are live without a restart or a file watcher. Workers and +/// services read their settings from here, never from IOptionsMonitor or appsettings.json. +/// +public sealed class ConfigCache +{ + private volatile IReadOnlyDictionary _values = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// Loads (or reloads) the whole config table. Decryption is handled by the repository. + public async Task LoadAsync(IConfigRepository repo, CancellationToken ct = default) + { + var all = await repo.GetAllAsync(ct); + var d = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var e in all) d[e.Key] = e.Value; + _values = d; + } + + /// Replaces the cache contents directly. For tests and pre-populated bootstrap scenarios. + public void LoadFrom(IReadOnlyDictionary values) => + _values = new Dictionary(values, StringComparer.OrdinalIgnoreCase); + + public string? GetRaw(string key) => _values.TryGetValue(key, out var v) ? v : null; + + public string GetString(string key, string def) => GetRaw(key) is { } v ? v : def; + + public int GetInt(string key, int def) => + int.TryParse(GetRaw(key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : def; + + public long GetLong(string key, long def) => + long.TryParse(GetRaw(key), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : def; + + public double GetDouble(string key, double def) => + double.TryParse(GetRaw(key), NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : def; + + public bool GetBool(string key, bool def) => + GetRaw(key) is { } v ? string.Equals(v, "true", StringComparison.OrdinalIgnoreCase) : def; + + public int[] GetIntArray(string key, int[] def) => TryJson(key) ?? def; + public double[] GetDoubleArray(string key, double[] def) => TryJson(key) ?? def; + public string[] GetStringArray(string key, string[] def) => TryJson(key) ?? def; + + private T? TryJson(string key) + { + var raw = GetRaw(key); + if (string.IsNullOrWhiteSpace(raw)) return default; + try { return JsonSerializer.Deserialize(raw); } + catch (JsonException) { return default; } + } + + // ---- Typed section snapshots (spec §12.3) ------------------------------------------------ + + public ListenerOptions Listener() => new() + { + Ports = GetIntArray(ConfigKeys.ListenerPorts, ListenerOptions.DefaultPorts), + ServerName = GetString(ConfigKeys.ListenerServerName, "Dispatch"), + // Empty = allow all; the safe baseline is the seeded default (ConfigDefaults), not a code fallback. + AllowedCidrs = GetStringArray(ConfigKeys.ListenerAllowedCidrs, []), + MaxMessageBytes = GetLong(ConfigKeys.ListenerMaxMessageBytes, 0), + RequireAuth = GetBool(ConfigKeys.ListenerRequireAuth, false), + AllowUnsecureAuth = GetBool(ConfigKeys.ListenerAllowUnsecureAuth, false), + // STARTTLS uses the shared TLS certificate (same cert as the HTTPS ingestion API). + TlsCertPath = GetString(ConfigKeys.TlsCertPath, ""), + TlsCertPassword = GetString(ConfigKeys.TlsCertPassword, ""), + ConnectionTimeoutSeconds = GetInt(ConfigKeys.ListenerConnectionTimeoutSeconds, 60), + MaxConnections = GetInt(ConfigKeys.ListenerMaxConnections, 100), + }; + + public ApiOptions Api() => new() + { + Port = GetInt(ConfigKeys.ApiPort, 8025), + HttpEnabled = GetBool(ConfigKeys.ApiHttpEnabled, true), + TlsEnabled = GetBool(ConfigKeys.ApiTlsEnabled, false), + TlsPort = GetInt(ConfigKeys.ApiTlsPort, 8026), + TlsCertPath = GetString(ConfigKeys.TlsCertPath, ""), + TlsCertPassword = GetString(ConfigKeys.TlsCertPassword, ""), + AllowedCidrs = GetStringArray(ConfigKeys.ApiAllowedCidrs, []), + MaxMessageBytes = GetLong(ConfigKeys.ApiMaxMessageBytes, 0), + RateLimitPerKey = GetInt(ConfigKeys.ApiRateLimitPerKey, 100), + }; + + /// Web UI settings. The TLS cert is the one exception that stays in appsettings (spec §12.1), + /// so it is supplied by the caller, not the config table. + public WebUiOptions WebUi() => new() + { + Port = GetInt(ConfigKeys.WebUiPort, 8420), + RequireHttps = GetBool(ConfigKeys.WebUiRequireHttps, true), + }; + + public SpoolOptions Spool() => new() + { + Directory = GetString(ConfigKeys.SpoolDirectory, "./.dispatch-spool"), + WorkerCount = GetInt(ConfigKeys.SpoolWorkerCount, 4), + }; +} diff --git a/src/Dispatch.Core/Configuration/ConfigDefaults.cs b/src/Dispatch.Core/Configuration/ConfigDefaults.cs new file mode 100644 index 00000000..c0f029ee --- /dev/null +++ b/src/Dispatch.Core/Configuration/ConfigDefaults.cs @@ -0,0 +1,83 @@ +namespace Dispatch.Core.Configuration; + +/// +/// First-run config seeding (spec §12.6): when the config table is missing a key, it is populated +/// with its default value so a freshly installed instance is immediately usable. Idempotent - existing +/// values (operator edits) are never overwritten. Secret keys (TLS passwords, relay credentials) are left +/// unset rather than seeded with an encrypted empty string. +/// +public static class ConfigDefaults +{ + // SMTP listener and ingestion API are CLOSED by default (spec §17.10): only listed source IPs may + // connect and an empty list denies everyone. Both default to loopback + private ranges (RFC1918 + + // IPv6 ULA) so same-host apps, private LANs and Docker networks work out of the box while the public + // internet can't - to open them an operator adds 0.0.0.0/0 + ::/0 deliberately in Access Control. + // The dashboard is the exception: it is password-protected and governed by its own middleware where + // an empty list = allow all, so a headless/NAT'd server stays reachable for first login. + private const string DashboardAllowAll = "[]"; + private const string PrivateRanges = + "[\"127.0.0.1/32\",\"::1/128\",\"10.0.0.0/8\",\"172.16.0.0/12\",\"192.168.0.0/16\",\"fc00::/7\"]"; + + /// The default key/value pairs, all non-encrypted. Values are stored verbatim (JSON for arrays). + public static readonly IReadOnlyDictionary Defaults = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [ConfigKeys.ListenerPorts] = "[25, 587]", // standard SMTP ports; listener falls back to 2525 if 25 is taken/unprivileged + [ConfigKeys.ListenerServerName] = "Dispatch", + [ConfigKeys.ListenerAllowedCidrs] = PrivateRanges, + [ConfigKeys.ListenerMaxMessageBytes] = "26214400", // 25 MiB default ceiling (0 = no limit) + [ConfigKeys.ListenerRequireAuth] = "false", + [ConfigKeys.ListenerAllowUnsecureAuth] = "false", // AUTH only after STARTTLS by default (no plaintext creds) + [ConfigKeys.ListenerConnectionTimeoutSeconds] = "60", + [ConfigKeys.ListenerMaxConnections] = "100", + + // Shared TLS certificate (SMTP STARTTLS + HTTPS API); unset until an operator generates/uploads one. + [ConfigKeys.TlsCertSource] = "", + + [ConfigKeys.SpoolDirectory] = "./.dispatch-spool", + [ConfigKeys.SpoolWorkerCount] = "4", + [ConfigKeys.SpoolMaxRetries] = "3", + [ConfigKeys.SpoolRetryDelaysSeconds] = "[30,300,1800]", + + [ConfigKeys.ApiEnabled] = "true", + [ConfigKeys.ApiPort] = "8025", + [ConfigKeys.ApiHttpEnabled] = "true", + [ConfigKeys.ApiTlsEnabled] = "false", + [ConfigKeys.ApiTlsPort] = "8026", + [ConfigKeys.ApiAllowedCidrs] = PrivateRanges, + [ConfigKeys.ApiMaxMessageBytes] = "26214400", // 25 MiB - bounds in-memory buffering of HTTP uploads (0 = no limit) + [ConfigKeys.ApiRateLimitPerKey] = "100", + + [ConfigKeys.WebUiPort] = "8420", + [ConfigKeys.WebUiAllowedCidrs] = DashboardAllowAll, + [ConfigKeys.WebUiSessionTimeoutMinutes] = "480", + [ConfigKeys.WebUiRequireHttps] = "true", + + [ConfigKeys.LoggingLogDelivered] = "true", + [ConfigKeys.LoggingLogRetrying] = "true", + [ConfigKeys.LoggingLogDenied] = "true", + + [ConfigKeys.PurgeEnabled] = "true", + [ConfigKeys.PurgeScheduleIntervalHours] = "6", + [ConfigKeys.PurgeSpoolFailedRetentionDays] = "7", + [ConfigKeys.PurgeCapturedRetentionDays] = "7", + [ConfigKeys.PurgeLogDeliveredRetentionDays] = "7", + [ConfigKeys.PurgeLogFailedRetentionDays] = "7", + [ConfigKeys.PurgeAuditRetentionDays] = "7", + [ConfigKeys.PurgeAuditSecurityRetentionDays] = "7", + [ConfigKeys.PurgeArchiveRetentionDays] = "0", // size-pressure JSONL archives: 0 = keep forever + [ConfigKeys.PurgeSizeTriggerGb] = "9.5", + [ConfigKeys.PurgeSizeTargetGb] = "9.0", + [ConfigKeys.UpdatesSelfManaged] = "false", // installers flip this on where a platform updater ships + }; + + /// Inserts any missing default key. Existing keys are left untouched. + public static async Task SeedAsync(IConfigRepository repo, CancellationToken ct = default) + { + var existing = (await repo.GetAllAsync(ct)).Select(e => e.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in Defaults) + { + if (existing.Contains(key)) continue; + await repo.SetAsync(key, value, encrypted: false, ct); + } + } +} diff --git a/src/Dispatch.Core/Configuration/ConfigKeys.cs b/src/Dispatch.Core/Configuration/ConfigKeys.cs new file mode 100644 index 00000000..c3d14021 --- /dev/null +++ b/src/Dispatch.Core/Configuration/ConfigKeys.cs @@ -0,0 +1,74 @@ +namespace Dispatch.Core.Configuration; + +/// +/// Canonical SQL config-table keys (spec §12.3). All Dispatch settings except the two bootstrap +/// items in appsettings (the DB connection string and the Web UI TLS cert path/password) live here. +/// +public static class ConfigKeys +{ + // Listener (SMTP) + public const string ListenerPorts = "listener.ports"; // JSON int[] + public const string ListenerServerName = "listener.server_name"; + public const string ListenerAllowedCidrs = "listener.allowed_cidrs"; // JSON string[] + public const string ListenerMaxMessageBytes = "listener.max_message_bytes"; + public const string ListenerRequireAuth = "listener.require_auth"; + public const string ListenerAllowUnsecureAuth = "listener.allow_unsecure_auth"; // AUTH over plaintext (no STARTTLS) + public const string ListenerTlsCertPath = "listener.tls_cert_path"; // deprecated - see Tls* (shared cert) + public const string ListenerTlsCertPassword = "listener.tls_cert_password"; // deprecated + public const string ListenerTlsCertSource = "listener.tls_cert_source"; // deprecated + public const string ListenerConnectionTimeoutSeconds = "listener.connection_timeout_seconds"; + public const string ListenerMaxConnections = "listener.max_connections"; + + // Shared TLS certificate - secures both the SMTP listener (STARTTLS) and the HTTPS ingestion API. + // (The dashboard keeps its own cert in appsettings / an auto self-signed cert.) + public const string TlsCertPath = "tls.cert_path"; + public const string TlsCertPassword = "tls.cert_password"; // encrypted + public const string TlsCertSource = "tls.cert_source"; // "generated" | "uploaded" | "" + + // Spool / worker + public const string SpoolDirectory = "spool.directory"; + public const string SpoolWorkerCount = "spool.worker_count"; + public const string SpoolMaxRetries = "spool.max_retries"; + public const string SpoolRetryDelaysSeconds = "spool.retry_delays_seconds"; // JSON double[] + + // HTTP ingestion API + public const string ApiEnabled = "api.enabled"; + public const string ApiPort = "api.port"; + public const string ApiHttpEnabled = "api.http_enabled"; // listen on the plain-HTTP port + public const string ApiTlsEnabled = "api.tls_enabled"; // also listen on an HTTPS port + public const string ApiTlsPort = "api.tls_port"; + public const string ApiAllowedCidrs = "api.allowed_cidrs"; // JSON string[] + public const string ApiMaxMessageBytes = "api.max_message_bytes"; + public const string ApiRateLimitPerKey = "api.rate_limit_per_key"; + + // Web UI + public const string WebUiPort = "webui.port"; + public const string WebUiAllowedCidrs = "webui.allowed_cidrs"; // JSON string[] + public const string WebUiSessionTimeoutMinutes = "webui.session_timeout_minutes"; + public const string WebUiRequireHttps = "webui.require_https"; + // Monotonic credential epoch: bumped on every admin-password change so existing cookies (which carry the + // epoch they were issued under) are rejected - i.e. changing the password signs out all other sessions. + public const string WebUiSessionEpoch = "webui.session_epoch"; + + // Logging suppression toggles (also referenced by SqlLoggingSettings) + public const string LoggingLogDelivered = "logging.log_delivered"; + public const string LoggingLogRetrying = "logging.log_retrying"; + public const string LoggingLogDenied = "logging.log_denied"; + + // Purge / retention (also referenced by SqlPurgeSettings) + public const string PurgeEnabled = "purge.enabled"; + public const string PurgeScheduleIntervalHours = "purge.schedule_interval_hours"; + public const string PurgeSpoolFailedRetentionDays = "purge.spool_failed_retention_days"; + public const string PurgeCapturedRetentionDays = "purge.captured_retention_days"; + public const string PurgeLogDeliveredRetentionDays = "purge.log_delivered_retention_days"; + public const string PurgeLogFailedRetentionDays = "purge.log_failed_retention_days"; + public const string PurgeAuditRetentionDays = "purge.audit_retention_days"; + public const string PurgeAuditSecurityRetentionDays = "purge.audit_security_retention_days"; + public const string PurgeArchiveRetentionDays = "purge.archive_retention_days"; + public const string PurgeSizeTriggerGb = "purge.size_trigger_gb"; + public const string PurgeSizeTargetGb = "purge.size_target_gb"; + + // Set true by installers that ship a platform updater (appliance / Linux / Windows), enabling the + // web-UI "upload an upgrade package" flow. False (e.g. Docker) hides/refuses in-app updates. + public const string UpdatesSelfManaged = "updates.self_managed"; +} diff --git a/src/Dispatch.Core/Configuration/DefaultRelayOptions.cs b/src/Dispatch.Core/Configuration/DefaultRelayOptions.cs new file mode 100644 index 00000000..0faeea4d --- /dev/null +++ b/src/Dispatch.Core/Configuration/DefaultRelayOptions.cs @@ -0,0 +1,17 @@ +using Dispatch.Core.Providers; + +namespace Dispatch.Core.Configuration; + +/// The single default relay used until SQL-backed relays/routing land (bound from "DefaultRelay"). +public sealed class DefaultRelayOptions +{ + public const string SectionName = "DefaultRelay"; + + public string Name { get; set; } = "default"; + public RelayProviderType Provider { get; set; } = RelayProviderType.Unconfigured; + public int MaxConcurrency { get; set; } = 4; + public long MaxMessageBytes { get; set; } = 0; + + /// Provider-specific settings, e.g. SMTP Host/Port/Username/Password/TlsMode. + public Dictionary Settings { get; set; } = new(); +} diff --git a/src/Dispatch.Core/Configuration/IConfigRepository.cs b/src/Dispatch.Core/Configuration/IConfigRepository.cs new file mode 100644 index 00000000..c34f781f --- /dev/null +++ b/src/Dispatch.Core/Configuration/IConfigRepository.cs @@ -0,0 +1,16 @@ +namespace Dispatch.Core.Configuration; + +/// +/// Key/value application settings stored in the SQL config table (spec §12.3). +/// Values flagged encrypted are transparently AES-encrypted at rest (§19.5). +/// +public interface IConfigRepository +{ + Task GetAsync(string key, CancellationToken ct = default); + Task SetAsync(string key, string value, bool encrypted = false, CancellationToken ct = default); + + /// All config rows, decrypted. Callers must redact encrypted values before returning over HTTP (§17.5). + Task> GetAllAsync(CancellationToken ct = default); +} + +public sealed record ConfigEntry(string Key, string Value, bool Encrypted, DateTime UpdatedAt); diff --git a/src/Dispatch.Core/Configuration/IPurgeSettings.cs b/src/Dispatch.Core/Configuration/IPurgeSettings.cs new file mode 100644 index 00000000..10660071 --- /dev/null +++ b/src/Dispatch.Core/Configuration/IPurgeSettings.cs @@ -0,0 +1,18 @@ +namespace Dispatch.Core.Configuration; + +/// +/// Live retention + auto-purge thresholds (spec §6.10, §12.3 purge.*). Backed by the SQL config table +/// with a short cache; falls back to the appsettings defaults when a key is +/// unset. The purge worker resolves this once per cycle (off the hot path), so the cache only avoids +/// redundant SQL round-trips when the size-pressure loop re-evaluates frequently. +/// +public interface IPurgeSettings +{ + ValueTask GetAsync(CancellationToken ct = default); +} + +/// Default that returns the static - used in tests and when no config store is wired. +public sealed class OptionsPurgeSettings(PurgeOptions options) : IPurgeSettings +{ + public ValueTask GetAsync(CancellationToken ct = default) => ValueTask.FromResult(options); +} diff --git a/src/Dispatch.Core/Configuration/IRetrySettings.cs b/src/Dispatch.Core/Configuration/IRetrySettings.cs new file mode 100644 index 00000000..a6696c46 --- /dev/null +++ b/src/Dispatch.Core/Configuration/IRetrySettings.cs @@ -0,0 +1,30 @@ +namespace Dispatch.Core.Configuration; + +/// +/// Live retry/back-off policy (spec §6.7, §12.3 spool.max_retries / spool.retry_delays_seconds). +/// Backed by the SQL config table with a short cache so the worker doesn't query per message; falls +/// back to the appsettings defaults when a key is unset. +/// +public interface IRetrySettings +{ + ValueTask GetAsync(CancellationToken ct = default); +} + +/// Resolved retry policy snapshot. +public sealed record RetryPolicy(int MaxRetries, double[] DelaysSeconds) +{ + /// Back-off before (1-based); the last delay repeats for further attempts. + public TimeSpan DelayFor(int attempt) + { + if (DelaysSeconds.Length == 0) return TimeSpan.Zero; + var idx = Math.Clamp(attempt - 1, 0, DelaysSeconds.Length - 1); + return TimeSpan.FromSeconds(DelaysSeconds[idx]); + } +} + +/// Default that returns the static - used in tests and when no config store is wired. +public sealed class OptionsRetrySettings(RetryOptions options) : IRetrySettings +{ + private readonly RetryPolicy _policy = new(options.MaxRetries, options.EffectiveDelaysSeconds); + public ValueTask GetAsync(CancellationToken ct = default) => ValueTask.FromResult(_policy); +} diff --git a/src/Dispatch.Core/Configuration/ListenerOptions.cs b/src/Dispatch.Core/Configuration/ListenerOptions.cs new file mode 100644 index 00000000..b97b8610 --- /dev/null +++ b/src/Dispatch.Core/Configuration/ListenerOptions.cs @@ -0,0 +1,51 @@ +namespace Dispatch.Core.Configuration; + +/// SMTP listener settings (bound from the "Listener" config section). +public sealed class ListenerOptions +{ + public const string SectionName = "Listener"; + + /// Ports to listen on. Empty falls back to (the standard 25 + 587). + /// Left empty by default so configuration values replace rather than append (array-binding quirk). + /// The listener probes each port at startup and falls back to 2525 only when 25 can't be bound - i.e. + /// it's already in use or the process lacks privilege (see SmtpListenerService). + public int[] Ports { get; set; } = []; + + public string ServerName { get; set; } = "Dispatch"; + + /// Application-layer source-IP allow-list. Empty = allow all; the safe baseline comes from the + /// seeded defaults (loopback + private ranges for the listener - see ), not a + /// code-level fallback, so an operator who clears the list in the dashboard is opting in to allow-all. + public string[] AllowedCidrs { get; set; } = []; + + /// Default SMTP ports: the standard submission/relay ports 25 and 587. Binding these needs + /// elevation (root, or CAP_NET_BIND_SERVICE on the systemd unit) - the installers and appliance run with + /// it. The listener falls back to when 25 isn't bindable. + public static readonly int[] DefaultPorts = [25, 587]; + + /// Unprivileged fallback used only when port 25 can't be bound (already in use or no privilege). + public const int FallbackPort = 2525; + + public int[] EffectivePorts => Ports is { Length: > 0 } ? Ports : DefaultPorts; + public string[] EffectiveAllowedCidrs => AllowedCidrs; + + /// Global max message size ceiling in bytes. 0 = no limit. + public long MaxMessageBytes { get; set; } = 0; + + /// Require SMTP AUTH against the configured credential allow-list (spec §5.3). + public bool RequireAuth { get; set; } = false; + + /// Allow SMTP AUTH over a plaintext (non-TLS) connection. Secure default is false - AUTH is + /// only offered after STARTTLS so credentials are never sent in the clear. Enable for internal/dev use. + public bool AllowUnsecureAuth { get; set; } = false; + + /// Path to a PFX certificate enabling STARTTLS (empty = plain text only). + public string TlsCertPath { get; set; } = ""; + public string TlsCertPassword { get; set; } = ""; + + /// Per-command wait timeout in seconds (spec §5.3). 0 = library default. + public int ConnectionTimeoutSeconds { get; set; } = 60; + + /// Max concurrent SMTP connections (spec §5.3). 0 = unlimited; over the cap MAIL FROM is refused 421. + public int MaxConnections { get; set; } = 100; +} diff --git a/src/Dispatch.Core/Configuration/PurgeOptions.cs b/src/Dispatch.Core/Configuration/PurgeOptions.cs new file mode 100644 index 00000000..7f23d960 --- /dev/null +++ b/src/Dispatch.Core/Configuration/PurgeOptions.cs @@ -0,0 +1,43 @@ +namespace Dispatch.Core.Configuration; + +/// Retention + auto-purge settings (spec §6.10), bound from the "Purge" config section. +public sealed class PurgeOptions +{ + public const string SectionName = "Purge"; + + public bool Enabled { get; set; } = true; + public double ScheduleIntervalHours { get; set; } = 6; + + /// spool/failed/ files older than this are deleted. + public int SpoolFailedRetentionDays { get; set; } = 7; + + /// spool/captured/ (local dev inbox) files older than this are deleted. + public int CapturedRetentionDays { get; set; } = 7; + + /// audit_log entries older than this are deleted (0 = keep forever). + public int AuditRetentionDays { get; set; } = 7; + + /// Noisier security audit events (allow-list denials, SMTP auth failures) kept this long (0 = forever). + public int AuditSecurityRetentionDays { get; set; } = 7; + + /// Weekly JSONL archive files written by the Express size-pressure purge are deleted after this + /// many days (0 = keep forever - the default, since they're emergency exports). + public int ArchiveRetentionDays { get; set; } + + public LogRetention Log { get; set; } = new(); + public SizePressureOptions SizePressure { get; set; } = new(); + + public sealed class LogRetention + { + public int DeliveredRetentionDays { get; set; } = 7; + public int FailedRetentionDays { get; set; } = 7; + public int RetryingRetentionDays { get; set; } = 7; + public int TestSentRetentionDays { get; set; } = 7; + } + + public sealed class SizePressureOptions + { + public double TriggerGb { get; set; } = 9.5; // SQL Server Express 10 GB limit − 0.5 GB buffer + public double TargetGb { get; set; } = 9.0; + } +} diff --git a/src/Dispatch.Core/Configuration/RetryOptions.cs b/src/Dispatch.Core/Configuration/RetryOptions.cs new file mode 100644 index 00000000..d867d124 --- /dev/null +++ b/src/Dispatch.Core/Configuration/RetryOptions.cs @@ -0,0 +1,19 @@ +namespace Dispatch.Core.Configuration; + +/// Retry/back-off policy for transient relay failures (bound from "Retry"). +public sealed class RetryOptions +{ + public const string SectionName = "Retry"; + + /// Maximum retry attempts before a message is moved to spool/failed/. + public int MaxRetries { get; set; } = 3; + + /// Back-off delay (seconds) before each retry; the last value repeats for further attempts (§6.7). + /// Empty falls back to . Left empty by default so configuration + /// values replace rather than append (array-binding quirk). + public double[] DelaysSeconds { get; set; } = []; + + public static readonly double[] DefaultDelaysSeconds = [30, 300, 1800]; + + public double[] EffectiveDelaysSeconds => DelaysSeconds is { Length: > 0 } ? DelaysSeconds : DefaultDelaysSeconds; +} diff --git a/src/Dispatch.Core/Configuration/SmtpPortResolver.cs b/src/Dispatch.Core/Configuration/SmtpPortResolver.cs new file mode 100644 index 00000000..f404261e --- /dev/null +++ b/src/Dispatch.Core/Configuration/SmtpPortResolver.cs @@ -0,0 +1,36 @@ +namespace Dispatch.Core.Configuration; + +/// +/// Pure decision logic for which SMTP ports to bind (spec §5). Kept separate from the listener so it can be +/// unit-tested without real sockets: callers pass a predicate. We prefer the +/// configured ports (25 + 587 by default) and fall back to (2525) +/// only when port 25 can't be bound (already in use or no privilege), or when nothing else bound. +/// +public static class SmtpPortResolver +{ + /// Configured ports, in preference order. + /// Returns true if the given port can be bound right now. + /// Optional sink for human-readable warnings about dropped ports / fallback. + /// The ports to actually listen on (may be empty if nothing can bind). + public static int[] Resolve(int[] requested, Func canBind, Action? warn = null) + { + var result = new List(); + var dropped = new List(); + foreach (var p in requested) + (canBind(p) ? result : dropped).Add(p); + + foreach (var p in dropped) + warn?.Invoke($"SMTP port {p} is unavailable (already in use or insufficient privilege) - skipping"); + + // Fall back to 2525 only when port 25 was wanted but couldn't be bound, or nothing bound at all. + if ((dropped.Contains(25) || result.Count == 0) + && !result.Contains(ListenerOptions.FallbackPort) + && canBind(ListenerOptions.FallbackPort)) + { + warn?.Invoke($"Falling back to SMTP port {ListenerOptions.FallbackPort} (port 25 unavailable)"); + result.Add(ListenerOptions.FallbackPort); + } + + return [.. result]; + } +} diff --git a/src/Dispatch.Core/Configuration/SpoolOptions.cs b/src/Dispatch.Core/Configuration/SpoolOptions.cs new file mode 100644 index 00000000..ba692f7b --- /dev/null +++ b/src/Dispatch.Core/Configuration/SpoolOptions.cs @@ -0,0 +1,13 @@ +namespace Dispatch.Core.Configuration; + +/// Spool directory + worker pool settings (bound from the "Spool" config section). +public sealed class SpoolOptions +{ + public const string SectionName = "Spool"; + + /// Root spool directory. Created on startup if missing. + public string Directory { get; set; } = "./.dispatch-spool"; + + /// Number of concurrent relay workers (clamped to 1..32). + public int WorkerCount { get; set; } = 4; +} diff --git a/src/Dispatch.Core/Configuration/WebUiOptions.cs b/src/Dispatch.Core/Configuration/WebUiOptions.cs new file mode 100644 index 00000000..cb61f3df --- /dev/null +++ b/src/Dispatch.Core/Configuration/WebUiOptions.cs @@ -0,0 +1,24 @@ +namespace Dispatch.Core.Configuration; + +/// Web UI / read-API settings (spec §9, §12), bound from the "WebUi" config section. +public sealed class WebUiOptions +{ + public const string SectionName = "WebUi"; + + public int Port { get; set; } = 8080; + + /// + /// When true the session cookie is marked Secure (sent only over HTTPS) and HSTS is enabled. + /// Set this once the dashboard is fronted by TLS / a reverse proxy. Defaults to false so the cookie + /// still works when the dashboard is reached over plain HTTP during local development. + /// + public bool RequireHttps { get; set; } = true; + + /// + /// Path to a PFX certificate for the dashboard HTTPS listener. This is the one exception to the + /// "everything in SQL" rule (spec §12.1/§12.2): ASP.NET Core needs it to start the HTTPS listener + /// before SQL is reachable, so it (and its password) live in appsettings.json, not the config table. + /// + public string TlsCertPath { get; set; } = ""; + public string TlsCertPassword { get; set; } = ""; +} diff --git a/src/Dispatch.Core/Counters/CounterField.cs b/src/Dispatch.Core/Counters/CounterField.cs new file mode 100644 index 00000000..389d308c --- /dev/null +++ b/src/Dispatch.Core/Counters/CounterField.cs @@ -0,0 +1,11 @@ +namespace Dispatch.Core.Counters; + +/// Daily aggregate counter fields (spec §6.11 relay_counters). +public enum CounterField +{ + Received, + Delivered, + Failed, + Retried, + Denied, +} diff --git a/src/Dispatch.Core/Counters/ICounterReader.cs b/src/Dispatch.Core/Counters/ICounterReader.cs new file mode 100644 index 00000000..be59abe2 --- /dev/null +++ b/src/Dispatch.Core/Counters/ICounterReader.cs @@ -0,0 +1,31 @@ +namespace Dispatch.Core.Counters; + +/// Today's aggregate counters for the dashboard (spec §9.2 - read from relay_counters). +public sealed record CounterTotals(long Received, long Delivered, long Failed, long Retried, long Denied); + +/// Today's aggregate counters for a single relay. +public sealed record RelayCounterTotals(int RelayId, long Received, long Delivered, long Failed, long Retried, long Denied); + +/// One day's totals across all relays (Reports time series). Date is "yyyy-MM-dd". +public sealed record DailyCounterTotals(string Date, long Received, long Delivered, long Failed, long Retried, long Denied); + +/// A relay's totals over a date range, with its display name (Reports per-relay table). +public sealed record RelayReportRow(int RelayId, string RelayName, long Received, long Delivered, long Failed, long Retried, long Denied); + +/// Read side of the daily counters, kept separate from the write-only . +public interface ICounterReader +{ + Task GetTodayAsync(CancellationToken ct = default); + + /// Today's counters broken down per relay (for the dashboard's active-relay badges). + Task> GetTodayByRelayAsync(CancellationToken ct = default); + + /// Summed totals over an inclusive UTC date range (Reports). + Task GetRangeTotalsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default); + + /// Per-day totals over an inclusive UTC date range, oldest first (Reports time series). + Task> GetDailyAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default); + + /// Per-relay totals over an inclusive UTC date range, busiest first (Reports per-relay table). + Task> GetRangeByRelayAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Counters/ICounterRepository.cs b/src/Dispatch.Core/Counters/ICounterRepository.cs new file mode 100644 index 00000000..30fb5532 --- /dev/null +++ b/src/Dispatch.Core/Counters/ICounterRepository.cs @@ -0,0 +1,10 @@ +namespace Dispatch.Core.Counters; + +/// +/// Always-written daily aggregates (spec §6.11). Backed by SQL relay_counters later; +/// in-memory for now. Increments must never throw into the relay path. +/// +public interface ICounterRepository +{ + Task IncrementAsync(int? relayId, CounterField field, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Counters/InMemoryCounterRepository.cs b/src/Dispatch.Core/Counters/InMemoryCounterRepository.cs new file mode 100644 index 00000000..989e2d2a --- /dev/null +++ b/src/Dispatch.Core/Counters/InMemoryCounterRepository.cs @@ -0,0 +1,52 @@ +using System.Collections.Concurrent; + +namespace Dispatch.Core.Counters; + +/// In-memory counter store (used in tests and as a dashboard source without SQL). +public sealed class InMemoryCounterRepository : ICounterRepository, ICounterReader +{ + private readonly ConcurrentDictionary<(int RelayId, CounterField Field), long> _counts = new(); + + public Task IncrementAsync(int? relayId, CounterField field, CancellationToken ct = default) + { + _counts.AddOrUpdate((relayId ?? 0, field), 1, (_, v) => v + 1); + return Task.CompletedTask; + } + + public long Get(int relayId, CounterField field) => + _counts.TryGetValue((relayId, field), out var v) ? v : 0; + + public Task GetTodayAsync(CancellationToken ct = default) + { + long Sum(CounterField f) => _counts.Where(kv => kv.Key.Field == f).Sum(kv => kv.Value); + return Task.FromResult(new CounterTotals( + Sum(CounterField.Received), Sum(CounterField.Delivered), Sum(CounterField.Failed), + Sum(CounterField.Retried), Sum(CounterField.Denied))); + } + + public Task> GetTodayByRelayAsync(CancellationToken ct = default) + { + long Get(int relay, CounterField f) => _counts.TryGetValue((relay, f), out var v) ? v : 0; + var result = _counts.Keys.Select(k => k.RelayId).Distinct() + .Select(r => new RelayCounterTotals(r, + Get(r, CounterField.Received), Get(r, CounterField.Delivered), Get(r, CounterField.Failed), + Get(r, CounterField.Retried), Get(r, CounterField.Denied))) + .ToList(); + return Task.FromResult>(result); + } + + // The in-memory store has no date dimension, so range queries collapse to the single bucket of totals. + public Task GetRangeTotalsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) => GetTodayAsync(ct); + + public async Task> GetDailyAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) + { + var t = await GetTodayAsync(ct); + return [new DailyCounterTotals(toUtc.ToString("yyyy-MM-dd"), t.Received, t.Delivered, t.Failed, t.Retried, t.Denied)]; + } + + public async Task> GetRangeByRelayAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) + { + var rows = await GetTodayByRelayAsync(ct); + return rows.Select(r => new RelayReportRow(r.RelayId, $"relay-{r.RelayId}", r.Received, r.Delivered, r.Failed, r.Retried, r.Denied)).ToList(); + } +} diff --git a/src/Dispatch.Core/Counters/MinuteCounterRing.cs b/src/Dispatch.Core/Counters/MinuteCounterRing.cs new file mode 100644 index 00000000..1ce4fb7c --- /dev/null +++ b/src/Dispatch.Core/Counters/MinuteCounterRing.cs @@ -0,0 +1,76 @@ +namespace Dispatch.Core.Counters; + +/// +/// In-memory per-minute rolling counters (last 60 minutes) for received and delivered (sent-to-provider) +/// messages. Drives the dashboard throughput sparkline (spec §9.2) and the rolling windows on /health. +/// Each bucket records which absolute minute it holds, so windowed sums ignore stale buckets. +/// +public sealed class MinuteCounterRing +{ + private readonly Ring _received = new(); + private readonly Ring _delivered = new(); + + public void RecordReceived() => _received.Record(); + public void RecordDelivered() => _delivered.Record(); + + /// Received in the last minutes. + public int SumReceived(int minutes) => _received.Sum(minutes); + + /// Delivered to providers in the last minutes. + public int SumDelivered(int minutes) => _delivered.Sum(minutes); + + /// Delivered-per-minute for the last 60 minutes, oldest→newest (sparkline). + public int[] Snapshot() => _delivered.SnapshotChronological(); + + private sealed class Ring + { + private const int Size = 60; + private readonly long[] _minute = new long[Size]; + private readonly int[] _count = new int[Size]; + private readonly Lock _lock = new(); + + public Ring() => Array.Fill(_minute, -1); + + private static long NowMinute() => DateTimeOffset.UtcNow.ToUnixTimeSeconds() / 60; + + public void Record() + { + var m = NowMinute(); + var i = (int)(m % Size); + lock (_lock) + { + if (_minute[i] != m) { _minute[i] = m; _count[i] = 0; } // reclaim a stale bucket + _count[i]++; + } + } + + public int Sum(int minutes) + { + var now = NowMinute(); + var lo = now - Math.Max(0, minutes - 1); + var total = 0; + lock (_lock) + { + for (var i = 0; i < Size; i++) + if (_minute[i] >= lo && _minute[i] <= now) total += _count[i]; + } + return total; + } + + public int[] SnapshotChronological() + { + var now = NowMinute(); + var snap = new int[Size]; + lock (_lock) + { + for (var k = 0; k < Size; k++) + { + var m = now - (Size - 1 - k); // oldest first + var i = (int)(((m % Size) + Size) % Size); + snap[k] = _minute[i] == m ? _count[i] : 0; + } + } + return snap; + } + } +} diff --git a/src/Dispatch.Core/Counters/RelayConcurrencyTracker.cs b/src/Dispatch.Core/Counters/RelayConcurrencyTracker.cs new file mode 100644 index 00000000..524cca03 --- /dev/null +++ b/src/Dispatch.Core/Counters/RelayConcurrencyTracker.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; + +namespace Dispatch.Core.Counters; + +/// +/// Tracks how many dispatches are in flight per relay right now (spec §9.2 "In Flight"). Updated by the +/// worker pool around each dispatch; read by the dashboard. In-memory and lock-free. +/// +public sealed class RelayConcurrencyTracker +{ + private readonly ConcurrentDictionary _inFlight = new(); + + public void Increment(int relayId) => _inFlight.AddOrUpdate(relayId, 1, (_, v) => v + 1); + + public void Decrement(int relayId) => _inFlight.AddOrUpdate(relayId, 0, (_, v) => Math.Max(0, v - 1)); + + public IReadOnlyDictionary Snapshot() => new Dictionary(_inFlight); + + public int InFlight(int relayId) => _inFlight.TryGetValue(relayId, out var v) ? v : 0; +} diff --git a/src/Dispatch.Core/Dispatch.Core.csproj b/src/Dispatch.Core/Dispatch.Core.csproj new file mode 100644 index 00000000..86b055fe --- /dev/null +++ b/src/Dispatch.Core/Dispatch.Core.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/src/Dispatch.Core/Logging/IDatabaseHealth.cs b/src/Dispatch.Core/Logging/IDatabaseHealth.cs new file mode 100644 index 00000000..3bde12f0 --- /dev/null +++ b/src/Dispatch.Core/Logging/IDatabaseHealth.cs @@ -0,0 +1,8 @@ +namespace Dispatch.Core.Logging; + +/// Fast, non-throwing database reachability probe for the /health endpoint. +public interface IDatabaseHealth +{ + /// Returns true if the database answers a trivial query within a short budget; never throws. + Task IsReachableAsync(CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Logging/ILogMaintenance.cs b/src/Dispatch.Core/Logging/ILogMaintenance.cs new file mode 100644 index 00000000..9e8b397b --- /dev/null +++ b/src/Dispatch.Core/Logging/ILogMaintenance.cs @@ -0,0 +1,29 @@ +using Dispatch.Core.Maintenance; + +namespace Dispatch.Core.Logging; + +/// relay_log maintenance for the purge worker (spec §6.10): time-based + size-pressure deletes. +public interface ILogMaintenance +{ + /// Deletes rows of older than , in + /// batches with a short pause, to avoid lock contention. Returns total rows deleted. + Task PurgeByRetentionAsync(string @event, int retentionDays, CancellationToken ct = default); + + /// Approximate database file size in bytes (allocated; for the /health + storage display). + Task GetDatabaseSizeBytesAsync(CancellationToken ct = default); + + /// Approximate USED bytes inside the database files (drops as rows are deleted). The + /// size-pressure purge keys off this so it frees a bounded amount and terminates, rather than the + /// allocated file size (which SQL Server never shrinks on DELETE). + Task GetDatabaseUsedBytesAsync(CancellationToken ct = default); + + /// True only when the database is a SQL Server Express edition, which caps each database's data + /// files at 10 GB. The size-pressure purge applies only here; a Standard/Enterprise/Azure/external server + /// has no such cap, so size-pressure is skipped entirely. + Task IsSizeCappedEditionAsync(CancellationToken ct = default); + + /// Size-pressure step: reads the oldest relay_log rows, hands them to + /// (which must persist them) and only then deletes them. Returns rows deleted. + /// If archiving throws, nothing is deleted. + Task ArchiveAndDeleteOldestRelayLogAsync(int batch, ArchiveRows archive, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Logging/ILogRepository.cs b/src/Dispatch.Core/Logging/ILogRepository.cs new file mode 100644 index 00000000..8660f7fb --- /dev/null +++ b/src/Dispatch.Core/Logging/ILogRepository.cs @@ -0,0 +1,10 @@ +namespace Dispatch.Core.Logging; + +/// +/// Writes relay_log rows (spec §6.11, §19.3). The ONLY SQL touch point in the worker. +/// Implementations MUST swallow failures - mail delivery must never fail because the log DB is down. +/// +public interface ILogRepository +{ + Task InsertAsync(RelayLogEntry entry, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Logging/ILoggingSettings.cs b/src/Dispatch.Core/Logging/ILoggingSettings.cs new file mode 100644 index 00000000..110e464b --- /dev/null +++ b/src/Dispatch.Core/Logging/ILoggingSettings.cs @@ -0,0 +1,21 @@ +namespace Dispatch.Core.Logging; + +/// +/// Live relay_log suppression toggles (spec §6.6, §12.3 logging.*). Counters are always written; these +/// only control whether the corresponding relay_log rows are inserted. Backed by the SQL config table +/// with a short cache so the worker doesn't query per message. +/// +public interface ILoggingSettings +{ + ValueTask LogDeliveredAsync(CancellationToken ct = default); + ValueTask LogRetryingAsync(CancellationToken ct = default); + ValueTask LogDeniedAsync(CancellationToken ct = default); +} + +/// Default that logs everything - used in tests and when no config store is wired. +public sealed class AlwaysLogSettings : ILoggingSettings +{ + public ValueTask LogDeliveredAsync(CancellationToken ct = default) => ValueTask.FromResult(true); + public ValueTask LogRetryingAsync(CancellationToken ct = default) => ValueTask.FromResult(true); + public ValueTask LogDeniedAsync(CancellationToken ct = default) => ValueTask.FromResult(true); +} diff --git a/src/Dispatch.Core/Logging/MessageLog.cs b/src/Dispatch.Core/Logging/MessageLog.cs new file mode 100644 index 00000000..1dbd7763 --- /dev/null +++ b/src/Dispatch.Core/Logging/MessageLog.cs @@ -0,0 +1,126 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Dispatch.Core.Logging; + +/// Opaque keyset cursor - the (logged_at, id) of the last row returned (spec §9.2). +public sealed record MessageLogCursor(DateTime LoggedAt, long Id); + +/// Filters for the Message Log query. All applied together; values are always parameterised (§17, §19). +public sealed class MessageLogFilter +{ + public DateTime? FromUtc { get; init; } + public DateTime? ToUtc { get; init; } + public string[]? Statuses { get; init; } // OK | Error | Denied (status column) + public string[]? Events { get; init; } // Delivered | Failed | Retrying | Denied | TestSent (event column, §9.2 chips) + public string? IngestSource { get; init; } // SMTP | API + public string? FromDomain { get; init; } + public string? ToDomain { get; init; } + public string? RelayName { get; init; } + public string? RoutingRuleName { get; init; } // restrict to messages routed by a named rule (§9.2) + public string? Subject { get; init; } // case-insensitive substring match on subject (§9.2) + public string? Tag { get; init; } + public int? ApiKeyId { get; init; } // restrict to one API key (per-key list, §7.4) + public int Limit { get; init; } = 50; + public MessageLogCursor? Cursor { get; init; } +} + +/// A projected Message Log row for the UI. +public sealed class MessageLogRow +{ + public long Id { get; init; } + public DateTime LoggedAt { get; init; } + public string Event { get; init; } = ""; + public string Status { get; init; } = ""; + public string SpoolId { get; init; } = ""; + public string FromAddress { get; init; } = ""; + public string ToDomain { get; init; } = ""; + /// Raw to_addresses JSON column (mapped by Dapper); not serialised - use . + [JsonIgnore] public string? ToAddressesJson { get; init; } + /// Full recipient list, parsed from , so the log can show real + /// addresses (and a +N count for multiple recipients) rather than only the domain. + public IReadOnlyList ToAddresses => MessageLogJson.ParseArray(ToAddressesJson); + public string? Subject { get; init; } + public string? RelayName { get; init; } + public string? Provider { get; init; } + public int? DurationMs { get; init; } + public int SizeBytes { get; init; } + public string IngestSource { get; init; } = ""; + public int RetryAttempt { get; init; } + public string? Error { get; init; } +} + +/// Shared JSON-array parsing for log rows (recipients/tags stored as JSON text columns). +internal static class MessageLogJson +{ + public static IReadOnlyList ParseArray(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try { return JsonSerializer.Deserialize(json) ?? []; } + catch (JsonException) { return []; } + } +} + +public sealed record MessageLogPage(IReadOnlyList Rows, MessageLogCursor? NextCursor); + +/// Offset-paginated result with a total count (for the Message Log page navigator). +public sealed record MessageLogPaged(IReadOnlyList Rows, int Total); + +/// Full per-message detail for the Message Log row-detail panel (spec §9.2). +public sealed class MessageLogDetail +{ + public long Id { get; init; } + public DateTime LoggedAt { get; init; } + public string Event { get; init; } = ""; + public string Status { get; init; } = ""; + public string SpoolId { get; init; } = ""; + public int RetryAttempt { get; init; } + public string FromAddress { get; init; } = ""; + public string FromDomain { get; init; } = ""; + public IReadOnlyList ToAddresses { get; init; } = []; + public string ToDomain { get; init; } = ""; + public string? Subject { get; init; } + public int SizeBytes { get; init; } + public string? RelayName { get; init; } + public string? RoutingRuleName { get; init; } + public bool RoutingMatched { get; init; } + public string? Provider { get; init; } + public string? ProviderMessageId { get; init; } + public string? ProviderResponse { get; init; } + public int? DurationMs { get; init; } + public string? Error { get; init; } + public string IngestSource { get; init; } = ""; + public string? SourceIp { get; init; } + public string? ApiKeyName { get; init; } + public IReadOnlyList Tags { get; init; } = []; + public string? XMailer { get; init; } + public int AttachmentCount { get; init; } + + /// All relay_log rows for this spool id, oldest first - the retry/attempt timeline (spec §9.2). + public IReadOnlyList History { get; init; } = []; +} + +/// One attempt in a message's retry history (spec §9.2 row-detail timeline). +public sealed record MessageLogAttempt( + DateTime LoggedAt, string Event, string Status, int RetryAttempt, string? Provider, int? DurationMs, string? Error); + +/// Keyset-paginated read of relay_log for the Message Log (spec §9.2, §19). +public interface IMessageLogQuery +{ + Task QueryAsync(MessageLogFilter filter, CancellationToken ct = default); + + /// Offset-paginated query with a total count, for the dashboard page navigator (spec §9.2). + /// rows are skipped; filter.Limit is the page size. + Task PageAsync(MessageLogFilter filter, int offset, CancellationToken ct = default); + + /// Most recent log row for a spool id, for delivery-status lookups (spec §7.4). When + /// is non-null the lookup is scoped to that key, so one key can't read + /// another key's message status by guessing its id. + Task GetBySpoolIdAsync(string spoolId, int? apiKeyId, CancellationToken ct = default); + + /// Recent log rows submitted with a given API key, newest first (per-key list, spec §7.4). + Task> RecentByApiKeyAsync(int apiKeyId, int limit, string[]? statuses, CancellationToken ct = default); + + /// Full detail for a single log row by id, for the row-detail panel (spec §9.2). Null if missing. + Task GetByIdAsync(long id, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Logging/NullLogRepository.cs b/src/Dispatch.Core/Logging/NullLogRepository.cs new file mode 100644 index 00000000..00be315a --- /dev/null +++ b/src/Dispatch.Core/Logging/NullLogRepository.cs @@ -0,0 +1,7 @@ +namespace Dispatch.Core.Logging; + +/// No-op log repository used until the SQL-backed relay_log repository lands (milestone: no SQL). +public sealed class NullLogRepository : ILogRepository +{ + public Task InsertAsync(RelayLogEntry entry, CancellationToken ct = default) => Task.CompletedTask; +} diff --git a/src/Dispatch.Core/Logging/RelayLogEntry.cs b/src/Dispatch.Core/Logging/RelayLogEntry.cs new file mode 100644 index 00000000..adc7481e --- /dev/null +++ b/src/Dispatch.Core/Logging/RelayLogEntry.cs @@ -0,0 +1,47 @@ +namespace Dispatch.Core.Logging; + +/// +/// A single relay_log event (spec §6.11). Written after-the-fact, never on the receive path. +/// Denormalised relay/rule names so history survives renames and deletes. +/// +public sealed class RelayLogEntry +{ + // Event + public string Event { get; init; } = ""; // Received | Delivered | Retrying | Failed | TestSent | Denied + public string Status { get; init; } = ""; // OK | Error | Denied + public int RetryAttempt { get; init; } + + // Identity + public string SpoolId { get; init; } = ""; + + // Envelope + public string FromAddress { get; init; } = ""; + public string FromDomain { get; init; } = ""; + public IReadOnlyList ToAddresses { get; init; } = []; + public string ToDomain { get; init; } = ""; + public string? Subject { get; init; } + public int SizeBytes { get; init; } + + // Routing + public int? RelayId { get; init; } + public string? RelayName { get; init; } + public int? RoutingRuleId { get; init; } + public string? RoutingRuleName { get; init; } + public bool RoutingMatched { get; init; } + + // Provider outcome + public string? Provider { get; init; } + public string? ProviderMessageId { get; init; } + public string? ProviderResponse { get; init; } + public int? DurationMs { get; init; } + public string? Error { get; init; } + + // Ingest source + public string IngestSource { get; init; } = "SMTP"; + public string? SourceIp { get; init; } + public int? ApiKeyId { get; init; } + public string? ApiKeyName { get; init; } + public IReadOnlyList? Tags { get; init; } + public string? XMailer { get; init; } + public int AttachmentCount { get; init; } +} diff --git a/src/Dispatch.Core/Maintenance/DiskMonitor.cs b/src/Dispatch.Core/Maintenance/DiskMonitor.cs new file mode 100644 index 00000000..ce95e02a --- /dev/null +++ b/src/Dispatch.Core/Maintenance/DiskMonitor.cs @@ -0,0 +1,96 @@ +using Dispatch.Core.Spool; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Dispatch.Core.Maintenance; + +/// Probes free disk space (bytes) on the spool partition. Injectable so tests can simulate pressure. +public delegate long FreeSpaceProbe(string spoolRoot); + +/// +/// Fast disk back-pressure monitor (spec §14.1). On a short ~10s timer it reads free space on the spool +/// partition and updates so inbound SMTP throttles/suspends well before the disk +/// fills. The slower evaluates the same probe on its cycle as a backstop. The +/// free-space source is injected (defaults to ) so it can be tested without real +/// disk pressure. All work is best-effort - a probe failure is logged and never crashes the service. +/// +public sealed class DiskMonitor : BackgroundService +{ + /// Default probe: available free bytes on the volume hosting . + public static long DriveFreeSpace(string spoolRoot) + { + var root = Path.GetPathRoot(Path.GetFullPath(spoolRoot)); + if (string.IsNullOrEmpty(root)) return long.MaxValue; + return new DriveInfo(root).AvailableFreeSpace; + } + + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(10); + + private readonly SpoolDirectory _spool; + private readonly IntakeState _intake; + private readonly FreeSpaceProbe _probe; + private readonly ILogger _log; + + public DiskMonitor(SpoolDirectory spool, IntakeState intake, ILogger log) + : this(spool, intake, DriveFreeSpace, log) { } + + /// Test/internal constructor allowing a simulated free-space probe. + public DiskMonitor(SpoolDirectory spool, IntakeState intake, FreeSpaceProbe probe, ILogger log) + { + _spool = spool; + _intake = intake; + _probe = probe; + _log = log; + } + + protected override async Task ExecuteAsync(CancellationToken ct) + { + Evaluate(); // act immediately on startup + while (!ct.IsCancellationRequested) + { + try { await Task.Delay(PollInterval, ct); } + catch (OperationCanceledException) { break; } + Evaluate(); + } + } + + /// + /// Reads free space and applies the corresponding intake level, logging transitions. Shared by the + /// fast timer and . Internal so tests can drive it directly. + /// + internal void Evaluate() + { + long freeBytes; + try { freeBytes = _probe(_spool.Root); } + catch (Exception ex) + { + _log.LogError(ex, "Disk free-space probe failed (intake level unchanged)"); + return; + } + + var previous = _intake.Level; + var next = _intake.Apply(freeBytes); + + if (next != previous) + { + const double mb = 1024d * 1024d; + switch (next) + { + case IntakeLevel.Suspended: + _log.LogError("Spool disk critically low ({FreeMb:F0} MB free) - SMTP intake SUSPENDED", freeBytes / mb); + break; + case IntakeLevel.Throttled: + _log.LogWarning("Spool disk low ({FreeMb:F0} MB free) - SMTP intake THROTTLED", freeBytes / mb); + break; + case IntakeLevel.Normal: + _log.LogInformation("Spool disk recovered ({FreeMb:F0} MB free) - SMTP intake NORMAL", freeBytes / mb); + break; + } + } + else if (next == IntakeLevel.Normal && freeBytes < IntakeState.WarnBytes) + { + // Below the warn threshold but not yet throttling - surface it without spamming on transitions. + _log.LogWarning("Spool disk free space low: {FreeMb:F0} MB", freeBytes / (1024d * 1024d)); + } + } +} diff --git a/src/Dispatch.Core/Maintenance/IStorageReport.cs b/src/Dispatch.Core/Maintenance/IStorageReport.cs new file mode 100644 index 00000000..693b69a8 --- /dev/null +++ b/src/Dispatch.Core/Maintenance/IStorageReport.cs @@ -0,0 +1,25 @@ +namespace Dispatch.Core.Maintenance; + +/// relay_log row count for a single event type (Delivered/Failed/Retrying/TestSent/Denied). +public sealed record LogEventCount(string Event, long Rows); + +/// +/// Database-side storage usage, so operators can see what their retention windows are actually holding +/// (spec §6.10 companion to retention). Per-event byte figures aren't cheap to get exactly in SQL Server, +/// so the endpoint estimates them by apportioning the relay_log table size across the event row counts; +/// these raw facts are what that estimate is built from. +/// +public sealed record DbStorage( + bool Connected, + long DatabaseBytes, + long RelayLogBytes, + IReadOnlyList RelayLogByEvent, + long AuditBytes, + long AuditRows, + long AuditSecurityRows); + +/// Reports how much database space the logged/audited data is using, broken out by category. +public interface IStorageReport +{ + Task GetAsync(CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Maintenance/IntakeState.cs b/src/Dispatch.Core/Maintenance/IntakeState.cs new file mode 100644 index 00000000..438dc06c --- /dev/null +++ b/src/Dispatch.Core/Maintenance/IntakeState.cs @@ -0,0 +1,53 @@ +namespace Dispatch.Core.Maintenance; + +/// Graduated intake back-pressure level driven by spool free disk space (spec §14.1). +public enum IntakeLevel +{ + /// Plenty of free disk - accept normally. + Normal = 0, + + /// Disk low - accept but delay each MAIL FROM by to slow the inbound rate. + Throttled = 1, + + /// Disk critically low - reject inbound so senders queue and retry (RFC 5321 4xx). + Suspended = 2, +} + +/// +/// Process-wide intake back-pressure flag (spec §14.1). A single mutable level shared between the +/// disk monitor (writer) and the SMTP mailbox filter / dashboard (readers). The level is set from +/// free disk space on the spool partition by and . +/// Register as a singleton. +/// +public sealed class IntakeState +{ + // Thresholds in bytes. Below Warn we log; below Throttle we delay; below Suspend we reject. + // Recovery uses the Throttle threshold so we return to Normal only once comfortably clear. + public const long WarnBytes = 1024L * 1024 * 1024; // 1 GB + public const long ThrottleBytes = 500L * 1024 * 1024; // 500 MB + public const long SuspendBytes = 200L * 1024 * 1024; // 200 MB + + /// How long delays each accepted MAIL FROM. + public static readonly TimeSpan ThrottleDelay = TimeSpan.FromSeconds(2); + + private volatile int _level = (int)IntakeLevel.Normal; + + /// The current back-pressure level. Thread-safe to read from any thread. + public IntakeLevel Level => (IntakeLevel)_level; + + /// + /// Maps free disk space (bytes) to a level and applies it, returning the new level. When already + /// throttled/suspended, recovery to a lower level requires crossing back above the throttle + /// threshold so we don't flap right at a boundary. + /// + public IntakeLevel Apply(long freeBytes) + { + IntakeLevel next; + if (freeBytes < SuspendBytes) next = IntakeLevel.Suspended; + else if (freeBytes < ThrottleBytes) next = IntakeLevel.Throttled; + else next = IntakeLevel.Normal; + + Interlocked.Exchange(ref _level, (int)next); + return next; + } +} diff --git a/src/Dispatch.Core/Maintenance/JsonlRowArchive.cs b/src/Dispatch.Core/Maintenance/JsonlRowArchive.cs new file mode 100644 index 00000000..1f8f1287 --- /dev/null +++ b/src/Dispatch.Core/Maintenance/JsonlRowArchive.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace Dispatch.Core.Maintenance; + +/// Hands a batch of database rows to a sink (archive them) - invoked by the size-pressure purge +/// just before the rows are deleted, so an emergency near-10GB purge never silently loses history. +public delegate Task ArchiveRows(IReadOnlyList> rows, CancellationToken ct); + +/// +/// Appends database rows to weekly JSON Lines files (one JSON object per line) under an archive directory, +/// grouped by the ISO week of each row's timestamp column - e.g. relay_log-2026-W26.jsonl. JSONL is +/// append-friendly and trivially re-ingestible/greppable. Only the (single-threaded) purge worker writes +/// here, so plain append is safe. +/// +public sealed class JsonlRowArchive(string archiveDir) +{ + private static readonly JsonSerializerOptions Json = new() { WriteIndented = false }; + + /// Appends to per-ISO-week files named + /// {table}-{isoYear}-W{week}.jsonl, choosing the week from each row's . + public void Append(string table, IReadOnlyList> rows, string timestampColumn) + { + if (rows.Count == 0) return; + Directory.CreateDirectory(archiveDir); + foreach (var week in rows.GroupBy(r => WeekKey(r, timestampColumn))) + { + var path = Path.Combine(archiveDir, $"{table}-{week.Key}.jsonl"); + using var stream = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read); + using var writer = new StreamWriter(stream, new UTF8Encoding(false)); + foreach (var row in week) + writer.WriteLine(JsonSerializer.Serialize(row, Json)); + } + } + + private static string WeekKey(IReadOnlyDictionary row, string column) + { + if (row.TryGetValue(column, out var v) && v is DateTime dt) + return string.Create(CultureInfo.InvariantCulture, $"{ISOWeek.GetYear(dt):D4}-W{ISOWeek.GetWeekOfYear(dt):D2}"); + return "undated"; + } +} diff --git a/src/Dispatch.Core/Maintenance/PurgeHistory.cs b/src/Dispatch.Core/Maintenance/PurgeHistory.cs new file mode 100644 index 00000000..730a4f38 --- /dev/null +++ b/src/Dispatch.Core/Maintenance/PurgeHistory.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; + +namespace Dispatch.Core.Maintenance; + +/// The outcome of one purge cycle (spec §9.2 purge history). +public sealed record PurgeRunResult( + DateTime RanAtUtc, + bool Manual, + int SpoolFilesDeleted, + int LogRowsDeleted, + long DatabaseSizeBytes); + +/// In-memory ring of the most recent purge runs (last 10), for the Settings → purge-history table. +public sealed class PurgeHistory +{ + private const int Capacity = 10; + private readonly ConcurrentQueue _runs = new(); + + public void Record(PurgeRunResult result) + { + _runs.Enqueue(result); + while (_runs.Count > Capacity && _runs.TryDequeue(out _)) { } + } + + /// Most recent runs first. + public IReadOnlyList Snapshot() => _runs.Reverse().ToList(); +} diff --git a/src/Dispatch.Core/Maintenance/PurgeWorker.cs b/src/Dispatch.Core/Maintenance/PurgeWorker.cs new file mode 100644 index 00000000..e5f89cd1 --- /dev/null +++ b/src/Dispatch.Core/Maintenance/PurgeWorker.cs @@ -0,0 +1,182 @@ +using Dispatch.Core.Audit; +using Dispatch.Core.Configuration; +using Dispatch.Core.Logging; +using Dispatch.Core.Spool; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Dispatch.Core.Maintenance; + +/// +/// Background retention + auto-purge (spec §6.10): deletes aged spool/failed and spool/captured files, +/// purges old relay_log rows per event type, and runs a size-pressure purge when the database nears the +/// SQL Server Express limit. Runs once on startup and then on a fixed interval. All work is best-effort - +/// a failure in one cycle is logged and never crashes the service. +/// +public sealed class PurgeWorker( + SpoolDirectory spool, + ILogMaintenance logs, + DiskMonitor diskMonitor, + IPurgeSettings settings, + PurgeHistory history, + ILogger log, + IAuditLog? audit = null) : BackgroundService +{ + private const long BytesPerGb = 1024L * 1024 * 1024; + + protected override async Task ExecuteAsync(CancellationToken ct) + { + // Resolve the (live, SQL-backed) settings once per cycle - retention/threshold edits in the web + // UI take effect on the next cycle without a restart. + var initial = await settings.GetAsync(ct); + if (!initial.Enabled) + { + log.LogInformation("Purge worker disabled"); + return; + } + + while (!ct.IsCancellationRequested) + { + var o = await settings.GetAsync(ct); + try + { + await RunOnceAsync(o, ct); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + log.LogError(ex, "Purge cycle failed"); + } + + var interval = TimeSpan.FromHours(Math.Max(0.1, o.ScheduleIntervalHours)); + try { await Task.Delay(interval, ct); } + catch (OperationCanceledException) { break; } + } + } + + public async Task RunOnceAsync(PurgeOptions o, CancellationToken ct, bool manual = false) + { + // Disk back-pressure backstop (spec §14.1): the fast DiskMonitor timer is primary, but the purge + // cycle re-evaluates so intake state stays current even if the timer is delayed. + diskMonitor.Evaluate(); + + var failed = PurgeFiles(spool.FailedDir, o.SpoolFailedRetentionDays, includeMeta: true); + var captured = PurgeFiles(spool.CapturedDir, o.CapturedRetentionDays, includeMeta: false); + // Age out old size-pressure JSONL archives (0 = keep forever). + var archives = PurgeFiles(spool.ArchiveDir, o.ArchiveRetentionDays, includeMeta: false, pattern: "*.jsonl"); + if (failed + captured + archives > 0) + log.LogInformation("Purged {Failed} failed, {Captured} captured spool files and {Archives} archives", failed, captured, archives); + + var logRows = 0; + logRows += await PurgeLogAsync("Delivered", o.Log.DeliveredRetentionDays, ct); + logRows += await PurgeLogAsync("Failed", o.Log.FailedRetentionDays, ct); + logRows += await PurgeLogAsync("Retrying", o.Log.RetryingRetentionDays, ct); + logRows += await PurgeLogAsync("TestSent", o.Log.TestSentRetentionDays, ct); + + logRows += await RunSizePressureAsync(o, ct); + + // Audit log retention (general + shorter window for noisy security events). + if (audit is not null) + { + var auditDeleted = await audit.PurgeAsync(o.AuditRetentionDays, o.AuditSecurityRetentionDays, ct); + if (auditDeleted > 0) log.LogInformation("Purged {Count} audit_log rows", auditDeleted); + } + + long dbSize; + try { dbSize = await logs.GetDatabaseSizeBytesAsync(ct); } catch { dbSize = 0; } + + var result = new PurgeRunResult(DateTime.UtcNow, manual, failed + captured, logRows, dbSize); + history.Record(result); + if (audit is not null && (result.SpoolFilesDeleted + result.LogRowsDeleted) > 0) + await audit.Lifecycle(manual ? "Storage cleanup ran (manual)" : "Storage cleanup ran", + $"Removed {result.SpoolFilesDeleted} spool files and {result.LogRowsDeleted} log rows."); + return result; + } + + private async Task PurgeLogAsync(string @event, int retentionDays, CancellationToken ct) + { + // 0 (or negative) means "keep forever" - the industry convention, consistent with the audit-log + // purge and the "0 = keep forever" hint in the Settings UI. Without this, retention 0 would compute + // a cutoff of "now" and delete everything. + if (retentionDays <= 0) return 0; + + var deleted = await logs.PurgeByRetentionAsync(@event, retentionDays, ct); + if (deleted > 0) + log.LogInformation("Purged {Count} {Event} log rows older than {Days}d", deleted, @event, retentionDays); + return deleted; + } + + private async Task RunSizePressureAsync(PurgeOptions o, CancellationToken ct) + { + // The 10 GB data-file cap only exists on SQL Server Express. On Standard/Enterprise/Azure or an + // operator's own external server there is no cap, so the size-pressure purge never runs there. + if (!await logs.IsSizeCappedEditionAsync(ct)) return 0; + + // Key off USED space, not the allocated file size (which never shrinks on DELETE) - so the loop + // actually converges and frees a bounded amount. + var used = await logs.GetDatabaseUsedBytesAsync(ct); + var trigger = (long)(o.SizePressure.TriggerGb * BytesPerGb); + if (used < trigger) return 0; + + var targetGb = Math.Min(o.SizePressure.TargetGb, o.SizePressure.TriggerGb); + var target = (long)(targetGb * BytesPerGb); + + log.LogWarning("Database using {UsedGb:F1} GB (Express 10 GB cap) - archiving + purging oldest rows down to {TargetGb:F1} GB", + used / (double)BytesPerGb, targetGb); + + // Archive rows to weekly JSONL before deleting them, so this emergency purge never silently loses + // history. Message-log first (usually the bulk), then audit when relay_log is exhausted. + var archive = new JsonlRowArchive(spool.ArchiveDir); + int total = 0, archivedRelay = 0, archivedAudit = 0; + while (!ct.IsCancellationRequested && await logs.GetDatabaseUsedBytesAsync(ct) > target) + { + var n = await logs.ArchiveAndDeleteOldestRelayLogAsync(500, + (rows, _) => { archive.Append("relay_log", rows, "logged_at"); return Task.CompletedTask; }, ct); + if (n > 0) { archivedRelay += n; total += n; await Task.Delay(100, ct); continue; } + + if (audit is not null) + { + n = await audit.ArchiveAndDeleteOldestAsync(500, + (rows, _) => { archive.Append("audit_log", rows, "logged_at"); return Task.CompletedTask; }, ct); + if (n > 0) { archivedAudit += n; total += n; await Task.Delay(100, ct); continue; } + } + break; // nothing left to free + } + + if (total > 0) + { + log.LogWarning("Size-pressure purge archived + deleted {Relay} message-log and {Audit} audit rows to {Dir}", + archivedRelay, archivedAudit, spool.ArchiveDir); + if (audit is not null) + await audit.Lifecycle("Size-pressure cleanup ran", + $"Database neared the 10 GB SQL Express cap; archived + deleted {archivedRelay} message-log and {archivedAudit} audit rows to {spool.ArchiveDir}.", + "Warning"); + } + return total; + } + + private static int PurgeFiles(string dir, int retentionDays, bool includeMeta, string pattern = "*.eml") + { + // 0 (or negative) means "keep forever" - see PurgeLogAsync. Guard before computing the cutoff, + // otherwise retention 0 would delete every file older than "now". + if (retentionDays <= 0 || !Directory.Exists(dir)) return 0; + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var count = 0; + foreach (var eml in Directory.EnumerateFiles(dir, pattern).ToList()) + { + try + { + if (File.GetLastWriteTimeUtc(eml) >= cutoff) continue; + File.Delete(eml); + count++; + if (includeMeta) + { + var meta = Path.ChangeExtension(eml, ".meta"); + if (File.Exists(meta)) File.Delete(meta); + } + } + catch { /* best-effort; a locked/in-use file is retried next cycle */ } + } + return count; + } +} diff --git a/src/Dispatch.Core/Maintenance/SmtpListenerState.cs b/src/Dispatch.Core/Maintenance/SmtpListenerState.cs new file mode 100644 index 00000000..bbaa3038 --- /dev/null +++ b/src/Dispatch.Core/Maintenance/SmtpListenerState.cs @@ -0,0 +1,21 @@ +namespace Dispatch.Core.Maintenance; + +/// +/// Process-wide record of the SMTP ports the listener actually bound (spec §5, §14.4). The configured +/// ports (listener.ports) and the bound ports can differ: the listener probes each port at startup +/// and falls back to 2525 when 25 can't be bound (in use or no privilege). The SMTP listener (writer) +/// publishes the resolved set here; the dashboard / /health (readers) surface it so operators see +/// what's truly listening, not just what was requested. Register as a singleton. +/// +public sealed class SmtpListenerState +{ + private volatile int[] _listeningPorts = []; + + /// The ports the listener is actually bound to (empty until it starts, or if none could bind). + /// Reference reads/writes are atomic; safe to read from any thread. + public int[] ListeningPorts + { + get => _listeningPorts; + set => _listeningPorts = value ?? []; + } +} diff --git a/src/Dispatch.Core/Providers/IRelayProvider.cs b/src/Dispatch.Core/Providers/IRelayProvider.cs new file mode 100644 index 00000000..d8db9408 --- /dev/null +++ b/src/Dispatch.Core/Providers/IRelayProvider.cs @@ -0,0 +1,12 @@ +namespace Dispatch.Core.Providers; + +/// +/// Upstream relay provider (spec §8). A new instance is built per dispatch from a +/// so multiple named relays of the same type can coexist. +/// Throw for retryable failures; any other exception is permanent. +/// +public interface IRelayProvider +{ + string Name { get; } + Task SendAsync(RelayMessage message, CancellationToken ct); +} diff --git a/src/Dispatch.Core/Providers/IRelayProviderFactory.cs b/src/Dispatch.Core/Providers/IRelayProviderFactory.cs new file mode 100644 index 00000000..72f590f4 --- /dev/null +++ b/src/Dispatch.Core/Providers/IRelayProviderFactory.cs @@ -0,0 +1,7 @@ +namespace Dispatch.Core.Providers; + +/// Builds the appropriate for a . +public interface IRelayProviderFactory +{ + IRelayProvider Build(RelayConfig config); +} diff --git a/src/Dispatch.Core/Providers/RelayConfig.cs b/src/Dispatch.Core/Providers/RelayConfig.cs new file mode 100644 index 00000000..720d855b --- /dev/null +++ b/src/Dispatch.Core/Providers/RelayConfig.cs @@ -0,0 +1,28 @@ +namespace Dispatch.Core.Providers; + +/// +/// A resolved relay configuration. In milestone 1 it is built from ; +/// later it is loaded from the SQL relays table (spec §6.11, §8). +/// +public sealed class RelayConfig +{ + public int Id { get; init; } = 1; + public string Name { get; init; } = "default"; + public RelayProviderType Provider { get; init; } = RelayProviderType.Unconfigured; + public int MaxConcurrency { get; init; } = 4; + public long MaxMessageBytes { get; init; } = 0; + public IReadOnlyDictionary Settings { get; init; } = + new Dictionary(); + + /// The size ceiling actually enforced: the configured value, or the provider-type default. + public long EffectiveMaxMessageBytes => + MaxMessageBytes > 0 ? MaxMessageBytes : ProviderDefaultMaxBytes(Provider); + + private static long ProviderDefaultMaxBytes(RelayProviderType t) => t switch + { + RelayProviderType.Mailgun => 25L * 1024 * 1024, + RelayProviderType.SendGrid => 30L * 1024 * 1024, + RelayProviderType.AzureCommunication => 10L * 1024 * 1024, + _ => 0, + }; +} diff --git a/src/Dispatch.Core/Providers/RelayMessage.cs b/src/Dispatch.Core/Providers/RelayMessage.cs new file mode 100644 index 00000000..a55d6292 --- /dev/null +++ b/src/Dispatch.Core/Providers/RelayMessage.cs @@ -0,0 +1,17 @@ +using MimeKit; + +namespace Dispatch.Core.Providers; + +/// A parsed message handed to an for upstream delivery. +public sealed class RelayMessage +{ + /// The fully parsed MIME message (parsed once by the worker, never on the receive path). + public required MimeMessage Message { get; init; } + + public required string FromAddress { get; init; } + public required IReadOnlyList ToAddresses { get; init; } + + public string? SpoolId { get; init; } + public IReadOnlyList? Tags { get; init; } + public long SizeBytes { get; init; } +} diff --git a/src/Dispatch.Core/Providers/RelayProviderType.cs b/src/Dispatch.Core/Providers/RelayProviderType.cs new file mode 100644 index 00000000..2f68f081 --- /dev/null +++ b/src/Dispatch.Core/Providers/RelayProviderType.cs @@ -0,0 +1,24 @@ +namespace Dispatch.Core.Providers; + +/// Upstream relay provider types (spec §8). +public enum RelayProviderType +{ + /// No provider chosen yet - the out-of-the-box default. Dispatch refuses to relay until a + /// provider is configured, so mail is never silently delivered or discarded. + Unconfigured = 0, + + /// Local / developer mode: never delivers externally; captures mail to spool/captured/ for + /// inspection and logs it as delivered (spec §8.5). + Local, + Smtp, + Mailgun, + SendGrid, + AzureCommunication, + AmazonSes, + Postmark, + Resend, + SparkPost, + Smtp2Go, + Maileroo, + Bird, +} diff --git a/src/Dispatch.Core/Providers/RelayResult.cs b/src/Dispatch.Core/Providers/RelayResult.cs new file mode 100644 index 00000000..c810e99f --- /dev/null +++ b/src/Dispatch.Core/Providers/RelayResult.cs @@ -0,0 +1,11 @@ +namespace Dispatch.Core.Providers; + +/// Outcome of a successful upstream delivery. +public sealed class RelayResult +{ + public string? ProviderMessageId { get; init; } + public string? ProviderDetail { get; init; } + + public static RelayResult Success(string? id = null, string? detail = null) => + new() { ProviderMessageId = id, ProviderDetail = detail }; +} diff --git a/src/Dispatch.Core/Providers/TransientRelayException.cs b/src/Dispatch.Core/Providers/TransientRelayException.cs new file mode 100644 index 00000000..87f40f2b --- /dev/null +++ b/src/Dispatch.Core/Providers/TransientRelayException.cs @@ -0,0 +1,8 @@ +namespace Dispatch.Core.Providers; + +/// Thrown by a provider when a delivery failure is transient and the message should be retried. +public sealed class TransientRelayException : Exception +{ + public TransientRelayException(string message) : base(message) { } + public TransientRelayException(string message, Exception inner) : base(message, inner) { } +} diff --git a/src/Dispatch.Core/Relays/IRelayRepository.cs b/src/Dispatch.Core/Relays/IRelayRepository.cs new file mode 100644 index 00000000..3859f44a --- /dev/null +++ b/src/Dispatch.Core/Relays/IRelayRepository.cs @@ -0,0 +1,18 @@ +using Dispatch.Core.Providers; + +namespace Dispatch.Core.Relays; + +/// Reads/writes named relay configurations (spec §6.11, §10.2). Read paths cache with a short TTL (§19.7). +public interface IRelayRepository +{ + Task GetDefaultAsync(CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + + Task CreateAsync(string name, RelayProviderType provider, int maxConcurrency, long maxMessageBytes, CancellationToken ct = default); + Task UpdateAsync(int id, string name, RelayProviderType provider, bool enabled, int maxConcurrency, long maxMessageBytes, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); + + /// Makes the given relay the default, demoting the current one atomically (spec §10.2). + Task SetDefaultAsync(int id, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Relays/RelayRecord.cs b/src/Dispatch.Core/Relays/RelayRecord.cs new file mode 100644 index 00000000..dc636570 --- /dev/null +++ b/src/Dispatch.Core/Relays/RelayRecord.cs @@ -0,0 +1,15 @@ +using Dispatch.Core.Providers; + +namespace Dispatch.Core.Relays; + +/// A row from the SQL relays table (spec §6.11). +public sealed class RelayRecord +{ + public int Id { get; init; } + public string Name { get; init; } = ""; + public RelayProviderType Provider { get; init; } + public bool IsDefault { get; init; } + public bool Enabled { get; init; } = true; + public int MaxConcurrency { get; init; } + public long MaxMessageBytes { get; init; } +} diff --git a/src/Dispatch.Core/Relays/RelaySettings.cs b/src/Dispatch.Core/Relays/RelaySettings.cs new file mode 100644 index 00000000..36fd4ea8 --- /dev/null +++ b/src/Dispatch.Core/Relays/RelaySettings.cs @@ -0,0 +1,87 @@ +using Dispatch.Core.Providers; + +namespace Dispatch.Core.Relays; + +/// A relay's provider type plus its generic provider settings (keys match what providers read). +public sealed record RelaySettings(RelayProviderType Provider, IReadOnlyDictionary Settings) +{ + public static RelaySettings Empty { get; } = new(RelayProviderType.Unconfigured, new Dictionary()); +} + +/// Describes one configurable field for a provider (drives validation, encryption, and the UI form). +public sealed record RelayFieldDescriptor(string Name, string ConfigSuffix, bool Secret, bool Required); + +/// The per-provider field schema. Maps generic setting names to SQL config key suffixes (§12.3). +public static class RelayProviderSchema +{ + public static IReadOnlyList For(RelayProviderType provider) => provider switch + { + RelayProviderType.Mailgun => + [ + new("ApiKey", "mailgun.api_key", Secret: true, Required: true), + new("Domain", "mailgun.domain", Secret: false, Required: true), + new("Region", "mailgun.region", Secret: false, Required: false), + ], + RelayProviderType.SendGrid => + [ + new("ApiKey", "sendgrid.api_key", Secret: true, Required: true), + ], + RelayProviderType.AzureCommunication => + [ + new("ConnectionString", "azure.connection_string", Secret: true, Required: true), + // One field for the verified MailFrom address(es). Storage suffix kept as the legacy + // "azure.sender_address" so existing relays' configured sender carries over unchanged. + new("MailFrom", "azure.sender_address", Secret: false, Required: true), + ], + RelayProviderType.Smtp => + [ + new("Host", "smtp.host", Secret: false, Required: true), + new("Port", "smtp.port", Secret: false, Required: false), + new("Username", "smtp.username", Secret: false, Required: false), + new("Password", "smtp.password", Secret: true, Required: false), + new("TlsMode", "smtp.tls_mode", Secret: false, Required: false), + ], + RelayProviderType.AmazonSes => + [ + new("AccessKeyId", "ses.access_key_id", Secret: false, Required: true), + new("SecretAccessKey", "ses.secret_access_key", Secret: true, Required: true), + new("Region", "ses.region", Secret: false, Required: true), + ], + RelayProviderType.Postmark => + [ + new("ApiKey", "postmark.server_token", Secret: true, Required: true), + new("MessageStream", "postmark.message_stream", Secret: false, Required: false), + ], + RelayProviderType.Resend => + [ + new("ApiKey", "resend.api_key", Secret: true, Required: true), + ], + RelayProviderType.SparkPost => + [ + new("ApiKey", "sparkpost.api_key", Secret: true, Required: true), + new("Region", "sparkpost.region", Secret: false, Required: false), + ], + RelayProviderType.Smtp2Go => + [ + new("ApiKey", "smtp2go.api_key", Secret: true, Required: true), + ], + RelayProviderType.Maileroo => + [ + new("ApiKey", "maileroo.api_key", Secret: true, Required: true), + ], + RelayProviderType.Bird => + [ + new("ApiKey", "bird.api_key", Secret: true, Required: true), + new("WorkspaceId", "bird.workspace_id", Secret: false, Required: true), + new("ChannelId", "bird.channel_id", Secret: false, Required: true), + ], + _ => [], + }; +} + +/// Reads/writes a relay's provider + credentials from the SQL config table (spec §12.3). +public interface IRelaySettingsStore +{ + Task GetAsync(int relayId, CancellationToken ct = default); + Task SaveAsync(int relayId, RelaySettings settings, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Routing/DomainMatcher.cs b/src/Dispatch.Core/Routing/DomainMatcher.cs new file mode 100644 index 00000000..19e75003 --- /dev/null +++ b/src/Dispatch.Core/Routing/DomainMatcher.cs @@ -0,0 +1,44 @@ +namespace Dispatch.Core.Routing; + +/// +/// Domain-based routing pattern matching (spec §10.4): exact (acme.com), single-level wildcard +/// (*.acme.com), comma-separated list, and catch-all (*). Patterns match the domain part only. +/// +public static class DomainMatcher +{ + public static bool Matches(string domain, string pattern) + { + if (string.IsNullOrEmpty(pattern)) return false; + if (pattern == "*") return true; + + foreach (var p in pattern.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + if (MatchSingle(domain, p)) + return true; + return false; + } + + private static bool MatchSingle(string domain, string pattern) + { + if (pattern == "*") return true; + if (pattern.StartsWith("*.", StringComparison.Ordinal)) + return domain.EndsWith("." + pattern[2..], StringComparison.OrdinalIgnoreCase); + return domain.Equals(pattern, StringComparison.OrdinalIgnoreCase); + } + + /// Specificity of a single pattern: exact = 2, single-level wildcard = 1, catch-all = 0 (spec §10.4). + public static int Specificity(string? pattern) + { + if (string.IsNullOrEmpty(pattern)) return 0; + // For a comma list, use the most specific member. + var best = 0; + foreach (var p in pattern.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + best = Math.Max(best, p == "*" ? 0 : p.StartsWith("*.", StringComparison.Ordinal) ? 1 : 2); + return best; + } + + public static string ExtractDomain(string address) + { + var at = address.LastIndexOf('@'); + return at >= 0 && at < address.Length - 1 ? address[(at + 1)..].ToLowerInvariant() : ""; + } +} diff --git a/src/Dispatch.Core/Routing/IRelayResolver.cs b/src/Dispatch.Core/Routing/IRelayResolver.cs new file mode 100644 index 00000000..b69ebac6 --- /dev/null +++ b/src/Dispatch.Core/Routing/IRelayResolver.cs @@ -0,0 +1,10 @@ +namespace Dispatch.Core.Routing; + +/// Selects the relay to use for a message based on sender/recipient (spec §10). +public interface IRelayResolver +{ + ValueTask ResolveAsync( + string fromAddress, + IReadOnlyList toAddresses, + CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Routing/ResolvedRelay.cs b/src/Dispatch.Core/Routing/ResolvedRelay.cs new file mode 100644 index 00000000..35c2bc4e --- /dev/null +++ b/src/Dispatch.Core/Routing/ResolvedRelay.cs @@ -0,0 +1,17 @@ +using Dispatch.Core.Providers; + +namespace Dispatch.Core.Routing; + +/// The relay selected for a message, plus which routing rule matched (if any). +public sealed class ResolvedRelay +{ + public required RelayConfig Config { get; init; } + + public int Id => Config.Id; + public string Name => Config.Name; + public int MaxConcurrency => Config.MaxConcurrency; + + public int? MatchedRuleId { get; init; } + public string? MatchedRuleName { get; init; } + public bool RoutingMatched => MatchedRuleId.HasValue; +} diff --git a/src/Dispatch.Core/Routing/RoutingEngine.cs b/src/Dispatch.Core/Routing/RoutingEngine.cs new file mode 100644 index 00000000..8d9417b1 --- /dev/null +++ b/src/Dispatch.Core/Routing/RoutingEngine.cs @@ -0,0 +1,76 @@ +using System.Collections.Concurrent; +using Dispatch.Core.Providers; +using Dispatch.Core.Relays; + +namespace Dispatch.Core.Routing; + +/// +/// Resolves the relay for a message (spec §10): evaluates enabled routing rules in priority/specificity +/// order against the sender + first-recipient domains, falling back to the default relay when none match. +/// The matched relay's identity comes from the relays table and its provider/credentials from +/// (SQL config, secrets decrypted). +/// +/// Resolution is deterministic per (sender-domain, recipient-domain) pair, so results are cached for a few +/// seconds. The spool claim loop calls this for every candidate file on every poll; without the cache a +/// backlog would issue an unbounded stream of rules/relay/settings queries against SQL each pass. +/// +public sealed class RoutingEngine( + IRoutingRuleRepository rules, + IRelayRepository relays, + IRelaySettingsStore settings) : IRelayResolver +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(5); + private readonly ConcurrentDictionary _cache = new(); + + public async ValueTask ResolveAsync( + string fromAddress, IReadOnlyList toAddresses, CancellationToken ct = default) + { + var fromDomain = DomainMatcher.ExtractDomain(fromAddress); + var toDomain = DomainMatcher.ExtractDomain(toAddresses.FirstOrDefault() ?? ""); + + var cacheKey = fromDomain + "\n" + toDomain; + if (_cache.TryGetValue(cacheKey, out var hit) && DateTime.UtcNow - hit.CachedAtUtc < CacheTtl) + return hit.Relay; + + var resolved = await ResolveUncachedAsync(fromDomain, toDomain, ct); + _cache[cacheKey] = (resolved, DateTime.UtcNow); + return resolved; + } + + private async ValueTask ResolveUncachedAsync( + string fromDomain, string toDomain, CancellationToken ct) + { + foreach (var rule in await rules.GetEnabledOrderedAsync(ct)) + { + var recipientMatch = rule.RecipientPattern is null || DomainMatcher.Matches(toDomain, rule.RecipientPattern); + var senderMatch = rule.SenderPattern is null || DomainMatcher.Matches(fromDomain, rule.SenderPattern); + if (recipientMatch && senderMatch) + { + var matched = await relays.GetByIdAsync(rule.RelayId, ct); + if (matched is { Enabled: true }) + return await BuildAsync(matched, rule.Id, rule.Name, ct); + // Referenced relay missing/disabled → fall through to default. + } + } + + var fallback = await relays.GetDefaultAsync(ct); + return await BuildAsync(fallback, matchedRuleId: null, matchedRuleName: null, ct); + } + + private async ValueTask BuildAsync( + RelayRecord? record, int? matchedRuleId, string? matchedRuleName, CancellationToken ct) + { + var id = record?.Id ?? 1; + var relaySettings = await settings.GetAsync(id, ct); + var config = new RelayConfig + { + Id = id, + Name = record?.Name ?? "default", + Provider = relaySettings.Provider, + MaxConcurrency = record?.MaxConcurrency ?? 4, + MaxMessageBytes = record?.MaxMessageBytes ?? 0, + Settings = relaySettings.Settings, + }; + return new ResolvedRelay { Config = config, MatchedRuleId = matchedRuleId, MatchedRuleName = matchedRuleName }; + } +} diff --git a/src/Dispatch.Core/Routing/RoutingRule.cs b/src/Dispatch.Core/Routing/RoutingRule.cs new file mode 100644 index 00000000..6ee7d91e --- /dev/null +++ b/src/Dispatch.Core/Routing/RoutingRule.cs @@ -0,0 +1,30 @@ +namespace Dispatch.Core.Routing; + +/// A row from the SQL routing_rules table (spec §10.3). +public sealed class RoutingRule +{ + public int Id { get; init; } + public int Priority { get; set; } + public string Name { get; init; } = ""; + public string? RecipientPattern { get; init; } // null = match any recipient + public string? SenderPattern { get; init; } // null = match any sender + public int RelayId { get; init; } + public bool Enabled { get; init; } = true; + + /// Combined specificity used to break ties within the same priority (spec §10.5). + public int Specificity => + DomainMatcher.Specificity(RecipientPattern) + DomainMatcher.Specificity(SenderPattern); +} + +/// Reads/writes the ordered routing rule table (spec §10.3, §10.10). +public interface IRoutingRuleRepository +{ + /// Enabled rules ordered by priority ASC then specificity DESC (spec §10.5). + Task> GetEnabledOrderedAsync(CancellationToken ct = default); + Task> GetAllAsync(CancellationToken ct = default); + Task CreateAsync(RoutingRule rule, CancellationToken ct = default); + Task UpdateAsync(RoutingRule rule, CancellationToken ct = default); + Task DeleteAsync(int id, CancellationToken ct = default); + Task ReorderAsync(IReadOnlyList idsInPriorityOrder, CancellationToken ct = default); + Task CountReferencingRelayAsync(int relayId, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Smtp/ISmtpCredentialRepository.cs b/src/Dispatch.Core/Smtp/ISmtpCredentialRepository.cs new file mode 100644 index 00000000..a375ad9e --- /dev/null +++ b/src/Dispatch.Core/Smtp/ISmtpCredentialRepository.cs @@ -0,0 +1,18 @@ +namespace Dispatch.Core.Smtp; + +/// A configured SMTP sender credential (spec §5.3) - never carries the password. +public sealed record SmtpCredential(int Id, string Username, DateTime CreatedAt, DateTime? LastUsedAt); + +/// Manages the SMTP AUTH allow-list stored in config_smtp_credentials (bcrypt-hashed). +public interface ISmtpCredentialRepository +{ + /// Verifies a username/password against the allow-list. Constant-time even when the user is unknown. + Task VerifyAsync(string username, string password, CancellationToken ct = default); + + Task> ListAsync(CancellationToken ct = default); + + /// Adds or updates a credential (username is unique). + Task AddAsync(string username, string password, CancellationToken ct = default); + + Task DeleteAsync(string username, CancellationToken ct = default); +} diff --git a/src/Dispatch.Core/Spool/MessageNormalizer.cs b/src/Dispatch.Core/Spool/MessageNormalizer.cs new file mode 100644 index 00000000..eb045b82 --- /dev/null +++ b/src/Dispatch.Core/Spool/MessageNormalizer.cs @@ -0,0 +1,33 @@ +using MimeKit; +using MimeKit.Utils; + +namespace Dispatch.Core.Spool; + +/// +/// Brings a relayed message up to the minimum RFC 5322 / deliverability bar before it leaves: a message MUST +/// carry a Date header (§3.6.1) and SHOULD carry a Message-Id (§3.6.4) - providers (Gmail, etc.) spam-filter +/// or reject mail missing them. We only add what the submitting client omitted; existing values are untouched. +/// +public static class MessageNormalizer +{ + /// Adds Date (set to ) and/or a generated Message-Id if absent. + /// Returns true if the message was changed. + public static bool EnsureRequiredHeaders(MimeMessage message, DateTimeOffset receivedAt) + { + var changed = false; + + if (!message.Headers.Contains(HeaderId.Date)) + { + message.Date = receivedAt; + changed = true; + } + + if (string.IsNullOrEmpty(message.MessageId)) + { + message.MessageId = MimeUtils.GenerateMessageId(); + changed = true; + } + + return changed; + } +} diff --git a/src/Dispatch.Core/Spool/SpoolDirectory.cs b/src/Dispatch.Core/Spool/SpoolDirectory.cs new file mode 100644 index 00000000..f33322c4 --- /dev/null +++ b/src/Dispatch.Core/Spool/SpoolDirectory.cs @@ -0,0 +1,67 @@ +using System.Threading.Channels; + +namespace Dispatch.Core.Spool; + +/// +/// Path helper + worker "doorbell" for the durable spool queue (spec §6, §19.3). +/// The three subdirectories are the source of truth for all in-flight messages. +/// +public sealed class SpoolDirectory +{ + public string Root { get; } + public string IncomingDir { get; } + public string ProcessingDir { get; } + public string FailedDir { get; } + + /// Where the local/dev (None) provider captures messages instead of delivering externally. + public string CapturedDir { get; } + + /// Where the Express size-pressure purge writes weekly JSONL exports of relay_log / audit_log + /// rows before deleting them, so an emergency near-10GB purge never silently loses history. + public string ArchiveDir { get; } + + // Bounded doorbell: filenames only. DropOldest because a dropped wake-up is harmless - + // the FileSystemWatcher and startup sweep guarantee files are still discovered. + private readonly Channel _doorbell = + Channel.CreateBounded(new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.DropOldest, + }); + + public SpoolDirectory(string root) + { + Root = Path.GetFullPath(root); + IncomingDir = Path.Combine(Root, "incoming"); + ProcessingDir = Path.Combine(Root, "processing"); + FailedDir = Path.Combine(Root, "failed"); + CapturedDir = Path.Combine(Root, "captured"); + ArchiveDir = Path.Combine(Root, "archive"); + // The spool holds full message bodies (and the .dispatch-key lives in the data dir) - restrict to the + // owner (700) on non-Windows so other local users can't read spooled mail. + CreatePrivate(Root); + CreatePrivate(IncomingDir); + CreatePrivate(ProcessingDir); + CreatePrivate(FailedDir); + CreatePrivate(CapturedDir); + CreatePrivate(ArchiveDir); + } + + private static void CreatePrivate(string dir) + { + Directory.CreateDirectory(dir); + if (!OperatingSystem.IsWindows()) + { + try { File.SetUnixFileMode(dir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } // 700 + catch { /* best-effort - a restrictive umask or unsupported FS shouldn't block startup */ } + } + } + + public string IncomingPath(Guid id) => Path.Combine(IncomingDir, $"{id}.eml"); + public string ProcessingPath(string filename) => Path.Combine(ProcessingDir, filename); + public string FailedPath(string filename) => Path.Combine(FailedDir, filename); + + /// Wake an idle worker. Best-effort - never blocks. + public void Signal(string filename) => _doorbell.Writer.TryWrite(filename); + + public ValueTask WaitAsync(CancellationToken ct) => _doorbell.Reader.ReadAsync(ct); +} diff --git a/src/Dispatch.Core/Spool/SpoolMessageStore.cs b/src/Dispatch.Core/Spool/SpoolMessageStore.cs new file mode 100644 index 00000000..2f0a575d --- /dev/null +++ b/src/Dispatch.Core/Spool/SpoolMessageStore.cs @@ -0,0 +1,170 @@ +using System.Buffers; +using System.Net; +using System.Text; +using Dispatch.Core.Configuration; +using Microsoft.Extensions.Logging; +using MimeKit; +using MimeKit.Utils; +using SmtpServer; +using SmtpServer.Mail; +using SmtpServer.Net; +using SmtpServer.Protocol; +using SmtpServer.Storage; + +namespace Dispatch.Core.Spool; + +/// +/// The ONLY work performed between SMTP DATA completion and 250 OK (spec §6.4, §19.3): +/// write raw bytes to spool/incoming/, write a small .meta sidecar, ring the doorbell. +/// No database, no network - the only failure mode that blocks 250 OK is a full disk. +/// +public sealed class SpoolMessageStore : MessageStore +{ + // RFC 5321 §6.3 loop defence: refuse a message that already carries an absurd number of Received + // headers (each hop adds one), so a misconfigured relay loop is broken instead of amplified. + private const int MaxReceivedHeaders = 30; + + private readonly SpoolDirectory _spool; + private readonly ConfigCache _config; + private readonly ILogger _log; + + public SpoolMessageStore(SpoolDirectory spool, ConfigCache config, ILogger log) + { + _spool = spool; + _config = config; + _log = log; + } + + public override async Task SaveAsync( + ISessionContext context, + IMessageTransaction transaction, + ReadOnlySequence buffer, + CancellationToken cancellationToken) + { + var id = Guid.NewGuid(); + var path = _spool.IncomingPath(id); + + var from = SafeAddress(transaction.From); + var to = transaction.To? + .Select(SafeAddress) + .Where(a => !string.IsNullOrEmpty(a)) + .ToArray() ?? []; + + // Prepend a Received: trace header (RFC 5321 §4.4) so the relay hop is recorded for debugging and + // loop detection, and so downstream raw-MIME providers forward a conformant message. + var receivedHeader = BuildReceivedHeader(context, id, to.FirstOrDefault()); + + // Disk write - the hot path. Stream the message straight to the spool file segment-by-segment so we + // never hold the whole body as an extra in-memory byte[] (bounded only by the SMTP SIZE ceiling). + long size; + await using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, 1 << 16, useAsync: true)) + { + await fs.WriteAsync(receivedHeader, cancellationToken); + foreach (var segment in buffer) + await fs.WriteAsync(segment, cancellationToken); + size = fs.Length; + } + + // Minimal header scan for X-Dispatch-Tag and a From fallback - not a full MIME parse. + string[]? tags = null; + string? xMailer = null; + var attachmentCount = 0; + var receivedCount = 0; + try + { + await using var rs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 1 << 16, useAsync: true); + var msg = await MimeMessage.LoadAsync(rs, cancellationToken); + receivedCount = msg.Headers.Count(h => h.Field.Equals("Received", StringComparison.OrdinalIgnoreCase)); + tags = msg.Headers + .Where(h => h.Field.Equals("X-Dispatch-Tag", StringComparison.OrdinalIgnoreCase)) + .Select(h => h.Value.Trim()) + .Where(v => v.Length > 0) + .ToArray(); + if (tags.Length == 0) tags = null; + + xMailer = msg.Headers["X-Mailer"]?.Trim(); + if (string.IsNullOrEmpty(xMailer)) xMailer = null; + attachmentCount = msg.Attachments.Count(); + + if (string.IsNullOrEmpty(from) && msg.From.Mailboxes.FirstOrDefault() is { } mb) + from = mb.Address; + } + catch (Exception ex) + { + _log.LogDebug(ex, "Header scan failed for {SpoolId}; storing without tags", id); + } + + // Mail-loop defence (RFC 5321 §6.3): too many Received headers means the message is bouncing between + // relays - drop it permanently rather than spool and amplify the loop. + if (receivedCount > MaxReceivedHeaders) + { + _log.LogWarning("Rejecting {SpoolId} from {From}: {Count} Received headers - mail loop", id, from, receivedCount); + try { File.Delete(path); } catch { /* best-effort cleanup */ } + return new SmtpResponse(SmtpReplyCode.TransactionFailed, "Too many Received headers - mail loop detected"); + } + + var meta = new SpoolMeta + { + SpoolId = id, + ReceivedAt = DateTime.UtcNow, + FromAddress = from, + ToAddresses = to, + IngestSource = "SMTP", + SourceIp = RemoteIp(context), + Tags = tags, + XMailer = xMailer, + AttachmentCount = attachmentCount, + }; + meta.Save(path); + + _spool.Signal(Path.GetFileName(path)); + _log.LogInformation( + "Received {SpoolId} from {From} ({Size} bytes) → 250 OK", id, from, size); + + return SmtpResponse.Ok; + } + + /// + /// Builds the RFC 5321 §4.4 Received: trace header for this hop: where it came from (HELO + source + /// IP), this server, the transport (ESMTP/ESMTPS), a queue id, the recipient, and the timestamp. + /// + private byte[] BuildReceivedHeader(ISessionContext context, Guid id, string? firstRecipient) + { + // Strip CR/LF from every interpolated value so this builder can never emit a smuggled header line, + // independent of upstream parsing guarantees (defence-in-depth against header injection). + var ip = NoCrLf(RemoteIp(context) ?? "unknown"); + var server = NoCrLf(_config.Listener().ServerName); + if (string.IsNullOrWhiteSpace(server)) server = NoCrLf(Environment.MachineName); + var secure = false; + try { secure = context.Pipe?.IsSecure ?? false; } catch { /* pipe state best-effort */ } + var proto = secure ? "ESMTPS" : "ESMTP"; + var queueId = id.ToString("N")[..16]; + var date = DateUtils.FormatDate(DateTimeOffset.UtcNow); + + var sb = new StringBuilder(); + sb.Append("Received: from [").Append(ip).Append("]\r\n"); + sb.Append("\tby ").Append(server).Append(" (Dispatch SMTP Relay) with ").Append(proto) + .Append(" id ").Append(queueId).Append("\r\n"); + if (!string.IsNullOrEmpty(firstRecipient)) + sb.Append("\tfor <").Append(NoCrLf(firstRecipient)).Append(">; ").Append(date).Append("\r\n"); + else + sb.Append("\t; ").Append(date).Append("\r\n"); + return Encoding.UTF8.GetBytes(sb.ToString()); + } + + /// Removes CR/LF so an interpolated value can't break out of its header line. + private static string NoCrLf(string s) => s.Replace("\r", "").Replace("\n", ""); + + private static string SafeAddress(IMailbox? mailbox) + { + if (mailbox is null || ReferenceEquals(mailbox, Mailbox.Empty)) return ""; + var addr = mailbox.AsAddress(); + return addr == "@" ? "" : addr; + } + + private static string? RemoteIp(ISessionContext context) => + context.Properties.TryGetValue(EndpointListener.RemoteEndPointKey, out var ep) + && ep is IPEndPoint ipep + ? ipep.Address.ToString() + : null; +} diff --git a/src/Dispatch.Core/Spool/SpoolMeta.cs b/src/Dispatch.Core/Spool/SpoolMeta.cs new file mode 100644 index 00000000..20f0a1b9 --- /dev/null +++ b/src/Dispatch.Core/Spool/SpoolMeta.cs @@ -0,0 +1,60 @@ +using System.Text.Json; + +namespace Dispatch.Core.Spool; + +/// +/// JSON sidecar ({uuid}.meta) holding envelope + retry state (spec §6.3). +/// The .eml is immutable; only the .meta changes across retries. +/// +public sealed class SpoolMeta +{ + public Guid SpoolId { get; set; } + public DateTime ReceivedAt { get; set; } + public string FromAddress { get; set; } = ""; + public string[] ToAddresses { get; set; } = []; + public string IngestSource { get; set; } = "SMTP"; // SMTP | API + public string? SourceIp { get; set; } + public int? ApiKeyId { get; set; } + public string? ApiKeyName { get; set; } + public string[]? Tags { get; set; } + /// Originating client from the X-Mailer header, if present (shown in the Message Log detail). + public string? XMailer { get; set; } + /// Number of attachments in the message (shown in the Message Log detail). + public int AttachmentCount { get; set; } + public int RetryCount { get; set; } + /// True once this message has been counted as Received, so crash-recovery can't double-count it. + public bool ReceivedCounted { get; set; } + public DateTime? NextRetryAt { get; set; } + public int? LastRelayId { get; set; } + public string? LastError { get; set; } + + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + /// The .meta path for a given .eml path (same name, .meta extension). + public static string PathFor(string emlPath) => Path.ChangeExtension(emlPath, ".meta"); + + public void Save(string emlPath) => + File.WriteAllText(PathFor(emlPath), JsonSerializer.Serialize(this, Json)); + + /// Full load - used by the worker after claiming a file. + public static SpoolMeta Load(string emlPath) => + JsonSerializer.Deserialize(File.ReadAllText(PathFor(emlPath)), Json)!; + + /// + /// Reads the .meta without requiring the .eml - used by the relay-aware claim loop. + /// Returns null if the meta is missing (file just written) or corrupt (being written). + /// + public static SpoolMeta? Peek(string emlPath) + { + var metaPath = PathFor(emlPath); + if (!File.Exists(metaPath)) return null; + try + { + return JsonSerializer.Deserialize(File.ReadAllText(metaPath), Json); + } + catch + { + return null; + } + } +} diff --git a/src/Dispatch.Core/Spool/SpoolWorkerPool.cs b/src/Dispatch.Core/Spool/SpoolWorkerPool.cs new file mode 100644 index 00000000..14c32256 --- /dev/null +++ b/src/Dispatch.Core/Spool/SpoolWorkerPool.cs @@ -0,0 +1,520 @@ +using Dispatch.Core.Audit; +using Dispatch.Core.Configuration; +using Dispatch.Core.Counters; +using Dispatch.Core.Logging; +using Dispatch.Core.Providers; +using Dispatch.Core.Routing; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace Dispatch.Core.Spool; + +/// +/// The relay worker pool (spec §6.5–§6.8). Recovers orphaned files on startup, then runs N +/// workers that claim spool files via per-relay semaphores and dispatch them through the +/// resolved , applying the success / transient-retry / permanent-fail +/// outcomes. SQL is only touched after the provider responds, and never fatally. +/// +public sealed class SpoolWorkerPool : BackgroundService +{ + private readonly SpoolDirectory _spool; + private readonly IRelayResolver _routing; + private readonly IRelayProviderFactory _providerFactory; + private readonly ILogRepository _logRepo; + private readonly ILoggingSettings _loggingSettings; + private readonly ICounterRepository _counters; + private readonly MinuteCounterRing _minuteRing; + private readonly RelayConcurrencyTracker _concurrency; + private readonly ILogger _log; + private readonly SpoolOptions _spoolOptions; + private readonly IRetrySettings _retry; + private readonly Dispatch.Core.Audit.IAuditLog? _audit; + + private readonly ConcurrentDictionary _semaphores = new(); + private readonly Dictionary _semaphoreMax = new(); // relayId → the max the semaphore was sized for + private readonly Lock _semLock = new(); + private FileSystemWatcher? _watcher; + + public SpoolWorkerPool( + SpoolDirectory spool, + IRelayResolver routing, + IRelayProviderFactory providerFactory, + ILogRepository logRepo, + ILoggingSettings loggingSettings, + ICounterRepository counters, + MinuteCounterRing minuteRing, + RelayConcurrencyTracker concurrency, + IOptions spoolOptions, + IRetrySettings retry, + ILogger log, + Dispatch.Core.Audit.IAuditLog? audit = null) + { + _spool = spool; + _routing = routing; + _providerFactory = providerFactory; + _logRepo = logRepo; + _loggingSettings = loggingSettings; + _counters = counters; + _minuteRing = minuteRing; + _concurrency = concurrency; + _spoolOptions = spoolOptions.Value; + _retry = retry; + _log = log; + _audit = audit; + } + + /// Per-relay concurrency gates. Exposed internally for tests. + internal ConcurrentDictionary Semaphores => _semaphores; + + /// + /// Test hook: simulate a dropped / never-firing FileSystemWatcher by disabling it, so that files + /// added afterwards are discovered only via the worker-loop timeout fallback (spec §14.1). + /// + internal void DisableWatcherForTests() + { + if (_watcher is not null) _watcher.EnableRaisingEvents = false; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + RecoverOrphans(); + + _watcher = new FileSystemWatcher(_spool.IncomingDir, "*.eml") + { + NotifyFilter = NotifyFilters.FileName, + EnableRaisingEvents = true, + }; + _watcher.Created += (_, e) => _spool.Signal(e.Name!); + + var workerCount = Math.Clamp(_spoolOptions.WorkerCount, 1, 32); + _log.LogInformation("Starting {Count} relay worker(s)", workerCount); + var workers = Enumerable.Range(0, workerCount) + .Select(_ => WorkerLoop(stoppingToken)) + .ToArray(); + + // Pick up anything already sitting in incoming/ at startup. + foreach (var f in Directory.EnumerateFiles(_spool.IncomingDir, "*.eml")) + _spool.Signal(Path.GetFileName(f)); + + try + { + await Task.WhenAll(workers); + } + catch (OperationCanceledException) + { + // normal shutdown + } + finally + { + _watcher.Dispose(); + } + } + + /// + /// FileSystemWatcher-fallback poll interval (spec §14.1). The OS can drop watcher events under heavy + /// load, so the doorbell wait times out and the worker attempts a claim anyway - files are never + /// stranded by a missed signal. Not latency-sensitive: it only matters when events are lost. + /// + private static readonly TimeSpan DoorbellTimeout = TimeSpan.FromSeconds(5); + + private async Task WorkerLoop(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + // Wait for a doorbell signal, but cap the wait so a dropped FileSystemWatcher event can't + // strand files: on timeout we fall through and still attempt a claim (fallback poll). + await _spool.WaitAsync(ct).AsTask().WaitAsync(DoorbellTimeout, ct); + } + catch (TimeoutException) + { + // No signal within the window - fall through to the fallback claim attempt below. + } + catch (OperationCanceledException) + { + break; + } + + ClaimedFile? claimed; + try + { + claimed = await ClaimFileForAvailableRelayAsync(ct); + } + catch (OperationCanceledException) + { + break; + } + + if (claimed is null) continue; + + var c = claimed.Value; + _concurrency.Increment(c.Relay.Id); + try + { + await ProcessAsync(c.EmlPath, c.Relay, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + _log.LogError(ex, "Unexpected error processing {File}", c.EmlPath); + } + finally + { + _concurrency.Decrement(c.Relay.Id); + c.Semaphore.Release(); + } + + // A file just freed a relay slot - nudge a worker to re-scan for more work. + _spool.Signal(Path.GetFileName(c.EmlPath)); + } + } + + // ---- Orphan recovery (spec §6.8) ------------------------------------------------------- + + internal void RecoverOrphans() + { + foreach (var eml in Directory.EnumerateFiles(_spool.ProcessingDir, "*.eml").ToList()) + { + var fileName = Path.GetFileName(eml); + var destEml = Path.Combine(_spool.IncomingDir, fileName); + try + { + File.Move(eml, destEml, overwrite: false); + var metaSrc = SpoolMeta.PathFor(eml); + if (File.Exists(metaSrc)) + File.Move(metaSrc, SpoolMeta.PathFor(destEml), overwrite: false); + _log.LogWarning("Recovered orphaned spool file {File}", fileName); + } + catch (Exception ex) + { + _log.LogError(ex, "Failed to recover orphan {File}", fileName); + } + } + } + + // ---- Relay-aware claim (spec §6.5) ----------------------------------------------------- + + internal readonly record struct ClaimedFile(string EmlPath, SemaphoreSlim Semaphore, ResolvedRelay Relay); + + internal async Task ClaimFileForAvailableRelayAsync(CancellationToken ct) + { + var candidates = Directory.EnumerateFiles(_spool.IncomingDir, "*.eml").ToList(); + + foreach (var candidate in candidates) + { + var meta = SpoolMeta.Peek(candidate); + if (meta is null) + { + // Missing/corrupt .meta. Normally this is just the sub-second window while the sidecar is being + // written; but a torn/corrupt sidecar would otherwise strand the .eml forever (the claim loop + // skips it on every pass). Quarantine it to failed/ once it is older than the grace period. + QuarantineUnreadable(candidate); + continue; + } + + if (meta.NextRetryAt is { } due && due > DateTime.UtcNow) continue; // back-off not elapsed + + var relay = await _routing.ResolveAsync(meta.FromAddress, meta.ToAddresses, ct); + var sem = GetSemaphoreFor(relay.Id, relay.MaxConcurrency); + + if (!sem.Wait(0)) continue; // relay at capacity - try next file + + var dest = _spool.ProcessingPath(Path.GetFileName(candidate)); + try + { + File.Move(candidate, dest); // atomic claim - first worker wins + } + catch (Exception ex) when (ex is IOException or FileNotFoundException) + { + sem.Release(); // another worker beat us - move on + continue; + } + catch + { + sem.Release(); // never leak the relay slot on an unexpected failure + throw; + } + + var metaSrc = SpoolMeta.PathFor(candidate); + if (File.Exists(metaSrc)) + { + try { File.Move(metaSrc, SpoolMeta.PathFor(dest), overwrite: true); } + catch (IOException) { /* meta moves best-effort; Load below will surface real problems */ } + } + + return new ClaimedFile(dest, sem, relay); + } + + return null; + } + + private static SemaphoreSlim CreateSemaphore(int maxConcurrency) + { + var max = maxConcurrency <= 0 ? int.MaxValue : maxConcurrency; + return new SemaphoreSlim(max, max); + } + + /// + /// Returns the per-relay concurrency gate, creating it on first use and REPLACING it when the relay's + /// max_concurrency has changed (spec §6.5). Workers already holding the old semaphore release the exact + /// instance they acquired (carried in ), so swapping the map entry is safe. + /// + private SemaphoreSlim GetSemaphoreFor(int relayId, int maxConcurrency) + { + lock (_semLock) + { + if (_semaphores.TryGetValue(relayId, out var existing) + && _semaphoreMax.TryGetValue(relayId, out var curMax) && curMax == maxConcurrency) + return existing; + + var sem = CreateSemaphore(maxConcurrency); + _semaphores[relayId] = sem; + _semaphoreMax[relayId] = maxConcurrency; + if (existing is not null) + _log.LogInformation("Relay {Relay} max_concurrency changed to {Max}; concurrency gate resized", relayId, maxConcurrency); + return sem; + } + } + + // ---- Dispatch + outcomes (spec §6.6) --------------------------------------------------- + + internal async Task ProcessAsync(string emlPath, ResolvedRelay relay, CancellationToken ct) + { + var meta = SpoolMeta.Load(emlPath); + var retry = await _retry.GetAsync(ct); + var sw = Stopwatch.StartNew(); + + // Count each message as received exactly once (off the hot path). Guarded by a persisted flag rather + // than RetryCount==0 so a crash-recovered file (moved processing→incoming and reprocessed while still + // on its first attempt) is not double-counted, which would inflate the received total and throughput. + if (!meta.ReceivedCounted) + { + await SafeIncrement(relay.Id, CounterField.Received, ct); + _minuteRing.RecordReceived(); + meta.ReceivedCounted = true; + try { meta.Save(emlPath); } + catch (Exception ex) { _log.LogDebug(ex, "Could not persist ReceivedCounted for {SpoolId}", meta.SpoolId); } + } + + try + { + MimeMessage mime; + await using (var fs = File.OpenRead(emlPath)) + mime = await MimeMessage.LoadAsync(fs, ct); + + // RFC 5322 requires a Date and providers penalise mail missing Date/Message-Id - add them if the + // submitting client didn't (using the spool receipt time for Date). Existing values are untouched. + MessageNormalizer.EnsureRequiredHeaders( + mime, new DateTimeOffset(DateTime.SpecifyKind(meta.ReceivedAt, DateTimeKind.Utc))); + + var relayMessage = new RelayMessage + { + Message = mime, + FromAddress = meta.FromAddress, + ToAddresses = meta.ToAddresses, + SpoolId = meta.SpoolId.ToString(), + Tags = meta.Tags, + SizeBytes = new FileInfo(emlPath).Length, + }; + + var provider = _providerFactory.Build(relay.Config); + var result = await provider.SendAsync(relayMessage, ct); + + // Counters are always accurate; the relay_log row is best-effort. + await SafeIncrement(relay.Id, CounterField.Delivered, ct); + _minuteRing.RecordDelivered(); + if (await _loggingSettings.LogDeliveredAsync(ct)) + await SafeLog(new RelayLogEntry + { + Event = "Delivered", + Status = "OK", + SpoolId = meta.SpoolId.ToString(), + FromAddress = meta.FromAddress, + FromDomain = ExtractDomain(meta.FromAddress), + ToAddresses = meta.ToAddresses, + ToDomain = ExtractDomain(meta.ToAddresses.FirstOrDefault() ?? ""), + Subject = mime.Subject, + SizeBytes = (int)relayMessage.SizeBytes, + RelayId = relay.Id, + RelayName = relay.Name, + RoutingRuleId = relay.MatchedRuleId, + RoutingRuleName = relay.MatchedRuleName, + RoutingMatched = relay.RoutingMatched, + Provider = provider.Name, + ProviderMessageId = result.ProviderMessageId, + ProviderResponse = result.ProviderDetail, + DurationMs = (int)sw.ElapsedMilliseconds, + IngestSource = meta.IngestSource, + SourceIp = meta.SourceIp, + ApiKeyId = meta.ApiKeyId, + ApiKeyName = meta.ApiKeyName, + Tags = meta.Tags, + XMailer = meta.XMailer, + AttachmentCount = meta.AttachmentCount, + }, ct); + + DeleteSpoolFiles(emlPath); + _log.LogInformation( + "Delivered {SpoolId} via {Relay} ({Provider}) in {Ms}ms", + meta.SpoolId, relay.Name, provider.Name, sw.ElapsedMilliseconds); + } + catch (Exception ex) when (IsTransient(ex) && meta.RetryCount < retry.MaxRetries) + { + await SafeIncrement(relay.Id, CounterField.Retried, ct); + if (await _loggingSettings.LogRetryingAsync(ct)) + await SafeLog(BuildErrorEntry("Retrying", meta, relay, ex, meta.RetryCount + 1), ct); + + meta.RetryCount++; + var delay = retry.DelayFor(meta.RetryCount); + meta.NextRetryAt = DateTime.UtcNow.Add(delay); + meta.LastError = ex.Message; + meta.LastRelayId = relay.Id; + + var incomingEml = MoveBackToIncoming(emlPath, meta); + _log.LogWarning( + "Transient failure for {SpoolId} (attempt {Attempt}): {Error}; retrying in {Delay}", + meta.SpoolId, meta.RetryCount, ex.Message, delay); + // Zero-delay retries get an immediate doorbell; delayed retries are picked up by the worker-loop + // fallback poll once NextRetryAt elapses (the claim loop honours NextRetryAt). This avoids spawning + // an unbounded number of fire-and-forget delay timers under a sustained provider outage. + if (delay <= TimeSpan.Zero) + _spool.Signal(Path.GetFileName(incomingEml)); + } + catch (Exception ex) + { + await SafeIncrement(relay.Id, CounterField.Failed, ct); + await SafeLog(BuildErrorEntry("Failed", meta, relay, ex, meta.RetryCount), ct); + + meta.LastError = ex.Message; + meta.LastRelayId = relay.Id; + MoveToFailed(emlPath, meta); + _log.LogError(ex, "Permanent failure for {SpoolId}: {Error}", meta.SpoolId, ex.Message); + if (_audit is not null) + await _audit.Relay($"Delivery failed via relay \"{relay.Name}\" - moved to Retry Queue", ex.Message); + } + } + + // ---- File transitions ------------------------------------------------------------------ + + private string MoveBackToIncoming(string processingEmlPath, SpoolMeta meta) + { + var fileName = Path.GetFileName(processingEmlPath); + var destEml = Path.Combine(_spool.IncomingDir, fileName); + + File.Move(processingEmlPath, destEml, overwrite: true); + meta.Save(destEml); + + var oldMeta = SpoolMeta.PathFor(processingEmlPath); + if (File.Exists(oldMeta)) File.Delete(oldMeta); + return destEml; + } + + private void MoveToFailed(string emlPath, SpoolMeta meta) + { + var fileName = Path.GetFileName(emlPath); + var destEml = _spool.FailedPath(fileName); + File.Move(emlPath, destEml, overwrite: true); + meta.Save(destEml); + + var oldMeta = SpoolMeta.PathFor(emlPath); + if (File.Exists(oldMeta)) File.Delete(oldMeta); + } + + private static void DeleteSpoolFiles(string emlPath) + { + File.Delete(emlPath); + var metaPath = SpoolMeta.PathFor(emlPath); + if (File.Exists(metaPath)) File.Delete(metaPath); + } + + // ---- Helpers --------------------------------------------------------------------------- + + /// + /// Grace period before an incoming .eml whose .meta is missing/unreadable is treated as corrupt rather + /// than mid-write. Generously larger than the sub-second ingest write window (spec §6.8 orphan handling). + /// + private static readonly TimeSpan UnreadableMetaGrace = TimeSpan.FromMinutes(5); + + private void QuarantineUnreadable(string emlPath) + { + DateTime writtenUtc; + try { writtenUtc = File.GetLastWriteTimeUtc(emlPath); } + catch { return; } // vanished (claimed/deleted by another worker) - nothing to do + + if (DateTime.UtcNow - writtenUtc < UnreadableMetaGrace) return; // still within the mid-write window + + try + { + var dest = _spool.FailedPath(Path.GetFileName(emlPath)); + File.Move(emlPath, dest, overwrite: true); + var meta = SpoolMeta.PathFor(emlPath); + if (File.Exists(meta)) + { + try { File.Move(meta, SpoolMeta.PathFor(dest), overwrite: true); } catch { /* best effort */ } + } + _log.LogError("Quarantined {File} to failed/: its .meta is missing or corrupt", Path.GetFileName(emlPath)); + } + catch (Exception ex) when (ex is IOException or FileNotFoundException) + { + // Raced with another worker that claimed/removed it - fine, it's no longer stranded. + } + catch (Exception ex) + { + _log.LogError(ex, "Failed to quarantine unreadable spool file {File}", Path.GetFileName(emlPath)); + } + } + + private static bool IsTransient(Exception ex) => ex is TransientRelayException; + + private static string ExtractDomain(string address) + { + var at = address.LastIndexOf('@'); + return at >= 0 && at < address.Length - 1 ? address[(at + 1)..] : ""; + } + + private RelayLogEntry BuildErrorEntry( + string @event, SpoolMeta meta, ResolvedRelay relay, Exception ex, int retryAttempt) => new() + { + Event = @event, + Status = "Error", + SpoolId = meta.SpoolId.ToString(), + RetryAttempt = retryAttempt, + Error = ex.Message, + FromAddress = meta.FromAddress, + FromDomain = ExtractDomain(meta.FromAddress), + ToAddresses = meta.ToAddresses, + ToDomain = ExtractDomain(meta.ToAddresses.FirstOrDefault() ?? ""), + RelayId = relay.Id, + RelayName = relay.Name, + RoutingRuleId = relay.MatchedRuleId, + RoutingRuleName = relay.MatchedRuleName, + RoutingMatched = relay.RoutingMatched, + IngestSource = meta.IngestSource, + SourceIp = meta.SourceIp, + ApiKeyId = meta.ApiKeyId, + ApiKeyName = meta.ApiKeyName, + Tags = meta.Tags, + XMailer = meta.XMailer, + AttachmentCount = meta.AttachmentCount, + }; + + private async Task SafeLog(RelayLogEntry entry, CancellationToken ct) + { + try { await _logRepo.InsertAsync(entry, ct); } + catch (Exception ex) { _log.LogError(ex, "relay_log insert failed (delivery unaffected)"); } + } + + private async Task SafeIncrement(int? relayId, CounterField field, CancellationToken ct) + { + try { await _counters.IncrementAsync(relayId, field, ct); } + catch (Exception ex) { _log.LogError(ex, "counter increment failed (delivery unaffected)"); } + } +} diff --git a/src/Dispatch.Core/Updates/UpdateBundleVerifier.cs b/src/Dispatch.Core/Updates/UpdateBundleVerifier.cs new file mode 100644 index 00000000..40d5e415 --- /dev/null +++ b/src/Dispatch.Core/Updates/UpdateBundleVerifier.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Dispatch.Core.Updates; + +/// +/// Authenticates an upgrade bundle before it is applied (spec: web-UI updates). Two independent checks, +/// both fail-closed: (1) the detached signature over the manifest verifies against the release public key +/// baked into the app (RSA PKCS#1 v1.5 / SHA-256 - interoperable with openssl dgst -sha256 -sign +/// in CI and openssl dgst -verify in the appliance updater), and (2) the payload tarball's SHA-256 +/// matches the digest in the (now-trusted) manifest. +/// +public sealed class UpdateBundleVerifier(string publicKeyPem) +{ + /// Verifier using the release public key embedded in this assembly. + public static UpdateBundleVerifier Default() => new(EmbeddedPublicKey()); + + /// True if is a valid RSA/SHA-256 signature of + /// under the release public key. + public bool VerifyManifestSignature(ReadOnlySpan manifestBytes, ReadOnlySpan signature) + { + using var rsa = RSA.Create(); + rsa.ImportFromPem(publicKeyPem); + try { return rsa.VerifyData(manifestBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); } + catch (CryptographicException) { return false; } // malformed signature → not authentic + } + + /// True if the SHA-256 of equals . + public static bool VerifyPayloadHash(Stream payload, string expectedSha256Hex) + { + using var sha = SHA256.Create(); + var actual = Convert.ToHexStringLower(sha.ComputeHash(payload)); + return CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(actual), + Encoding.ASCII.GetBytes((expectedSha256Hex ?? "").Trim().ToLowerInvariant())); + } + + /// The PEM release public key embedded from Updates/dispatch-update-public.pem. + public static string EmbeddedPublicKey() + { + var asm = typeof(UpdateBundleVerifier).Assembly; + var name = Array.Find(asm.GetManifestResourceNames(), n => n.EndsWith("dispatch-update-public.pem", StringComparison.Ordinal)) + ?? throw new InvalidOperationException("Embedded update public key not found."); + using var s = asm.GetManifestResourceStream(name)!; + using var r = new StreamReader(s); + return r.ReadToEnd(); + } +} diff --git a/src/Dispatch.Core/Updates/UpdateManifest.cs b/src/Dispatch.Core/Updates/UpdateManifest.cs new file mode 100644 index 00000000..9c067214 --- /dev/null +++ b/src/Dispatch.Core/Updates/UpdateManifest.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; + +namespace Dispatch.Core.Updates; + +/// One platform payload inside an upgrade package: its file name within the package and the +/// SHA-256 of that file. +public sealed record UpdateArtifact( + [property: JsonPropertyName("file")] string File, + [property: JsonPropertyName("sha256")] string Sha256); + +/// +/// Describes a single, cross-platform upgrade package (spec: web-UI updates). ONE package per release +/// carries every platform's self-contained payload under (keyed by runtime id, +/// e.g. linux-x64, linux-arm64, win-x64); the box applies whichever matches its arch. +/// Emitted by release.yml and signed (detached RSA/SHA-256) so a host can authenticate it before applying. +/// +public sealed record UpdateManifest( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("minFromVersion")] string MinFromVersion, + [property: JsonPropertyName("builtAt")] string BuiltAt, + [property: JsonPropertyName("notesUrl")] string? NotesUrl, + [property: JsonPropertyName("artifacts")] Dictionary Artifacts); diff --git a/src/Dispatch.Core/Updates/UpdateStatus.cs b/src/Dispatch.Core/Updates/UpdateStatus.cs new file mode 100644 index 00000000..412f4f5e --- /dev/null +++ b/src/Dispatch.Core/Updates/UpdateStatus.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace Dispatch.Core.Updates; + +/// Progress of an in-place update. The platform updater (Linux systemd/bash or the Windows +/// helper) writes this to updates/status.json; the dashboard reads it to show progress. +// Ordered by the progress the dashboard shows: the upload is received, its signature/compatibility verified, +// the payload unpacked, staged, then the platform updater applies it and restarts the service. Older writers +// only emit Staged/Applying/Succeeded/Failed/RolledBack; the extra states are optional finer-grained steps. +public enum UpdateState { Idle, Receiving, Verifying, Extracting, Staged, Applying, Restarting, Succeeded, Failed, RolledBack } + +/// Status surfaced to the dashboard. State is serialized as a string for the bash/JSON interop. +public sealed record UpdateStatus( + [property: JsonPropertyName("state")] UpdateState State, + [property: JsonPropertyName("version")] string? Version, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("updatedAtUtc")] DateTime UpdatedAtUtc); + +/// The hand-off record the web service writes to updates/apply.request once a bundle is +/// verified and staged; the platform updater picks it up, applies the staged bundle, restarts, and writes +/// back . +public sealed record ApplyRequest( + [property: JsonPropertyName("version")] string Version, + [property: JsonPropertyName("arch")] string Arch, + [property: JsonPropertyName("stagedDir")] string StagedDir, + [property: JsonPropertyName("fromVersion")] string FromVersion, + [property: JsonPropertyName("requestedAtUtc")] DateTime RequestedAtUtc); diff --git a/src/Dispatch.Core/Updates/dispatch-update-public.pem b/src/Dispatch.Core/Updates/dispatch-update-public.pem new file mode 100644 index 00000000..dbf2e0bb --- /dev/null +++ b/src/Dispatch.Core/Updates/dispatch-update-public.pem @@ -0,0 +1,11 @@ +-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAzzM3zhzCU0Ppi63n0Qts +pzpHidGsMgFZFgMIqNXW5c6ha52b1IeUoPtxLcs3sKz4Jg+OzpFAfZ66PbxYx4L4 +jEyy7SA6WDxI0n1f3sFYWXu1VnZshJ+6ZY6ohZRhZ5sVkJvCbAFSpwp92vLAX2c0 +pydDztUMIN01OcIoqbPLgeXK+K+IHTAWWe59w+FpvRyNxELgXDAcuG87D/eAfyJP +xFFkjKGRytjxfDlpvBYf4/J7A3i4P2xkSmmy5CDxQA8jfliz/tC7AB3f7rnt2vD4 +RW5cAEdQIbqrkzXACWtJP9B0ZWTMr6WpjVZgMzD1Qaea6SMswZHufGFfKC/ap3x9 +yVh0GkwfRxAbK5X3W0BTO/aUxjDAi2e259ZOhe8H+Kby4KQgD1nQxDLd3hw3VJXE +mECOlas9z4oprwt43SSMeLx/2aJ7J/57ib57eslSGUcqZ3vossTNna28OEQhS8+g +Po37wn+Kzv01HvSjw0ztKDNEsPooS2sYx2U6OTZHjC8BAgMBAAE= +-----END PUBLIC KEY----- diff --git a/src/Dispatch.Data/DatabaseInitializer.cs b/src/Dispatch.Data/DatabaseInitializer.cs new file mode 100644 index 00000000..35c67132 --- /dev/null +++ b/src/Dispatch.Data/DatabaseInitializer.cs @@ -0,0 +1,128 @@ +using System.Reflection; +using Dapper; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; + +namespace Dispatch.Data; + +/// +/// Ensures the target database exists and applies ordered, embedded SQL migrations once each, +/// tracking applied versions in schema_version (spec §6.11, §12). Idempotent and safe to +/// run on every startup. +/// +public sealed class DatabaseInitializer(SqlConnectionFactory factory, ILogger log) +{ + private const string MigrationPrefix = ".Migrations."; + + public async Task InitializeAsync(CancellationToken ct = default) + { + var builder = new SqlConnectionStringBuilder(factory.ConnectionString); + var database = builder.InitialCatalog; + if (string.IsNullOrWhiteSpace(database)) + throw new InvalidOperationException("Connection string must specify an Initial Catalog (Database)."); + + await EnsureDatabaseAsync(builder, database, ct); + await EnsureSchemaVersionTableAsync(ct); + await ApplyMigrationsAsync(database, ct); + } + + private async Task EnsureDatabaseAsync(SqlConnectionStringBuilder builder, string database, CancellationToken ct) + { + var masterBuilder = new SqlConnectionStringBuilder(builder.ConnectionString) { InitialCatalog = "master" }; + await using var cn = new SqlConnection(masterBuilder.ConnectionString); + await OpenWithRetryAsync(cn, ct); + + var exists = await cn.ExecuteScalarAsync( + "SELECT 1 FROM sys.databases WHERE name = @database", new { database }); + if (exists is null) + { + // CREATE DATABASE cannot be parameterised; database name comes from our own config, not user input. + await cn.ExecuteAsync($"CREATE DATABASE [{database.Replace("]", "]]")}]"); + log.LogInformation("Created database {Database}", database); + } + } + + private async Task EnsureSchemaVersionTableAsync(CancellationToken ct) + { + await using var cn = await factory.OpenAsync(ct); + await cn.ExecuteAsync(""" + IF OBJECT_ID('schema_version') IS NULL + CREATE TABLE schema_version ( + version INT NOT NULL PRIMARY KEY, + script_name NVARCHAR(256) NOT NULL, + applied_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() + ); + """); + } + + private async Task ApplyMigrationsAsync(string database, CancellationToken ct) + { + await using var cn = await factory.OpenAsync(ct); + var applied = (await cn.QueryAsync("SELECT version FROM schema_version")).ToHashSet(); + + foreach (var (version, name, sql) in LoadMigrations()) + { + if (applied.Contains(version)) + continue; + + await using var tx = await cn.BeginTransactionAsync(ct); + try + { + await cn.ExecuteAsync(sql, transaction: tx); + await cn.ExecuteAsync( + "INSERT INTO schema_version (version, script_name) VALUES (@version, @name)", + new { version, name }, tx); + await tx.CommitAsync(ct); + log.LogInformation("Applied migration {Version} ({Name})", version, name); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } + } + + /// Opens a connection, retrying for up to ~60s so a just-started SQL container (CI/docker) is tolerated. + private async Task OpenWithRetryAsync(SqlConnection cn, CancellationToken ct) + { + const int maxAttempts = 30; + for (var attempt = 1; ; attempt++) + { + try + { + await cn.OpenAsync(ct); + return; + } + catch (SqlException) when (attempt < maxAttempts) + { + if (attempt == 1) log.LogInformation("Waiting for SQL Server to accept connections…"); + await Task.Delay(TimeSpan.FromSeconds(2), ct); + } + } + } + + private static IEnumerable<(int Version, string Name, string Sql)> LoadMigrations() + { + var asm = typeof(DatabaseInitializer).Assembly; + var migrations = new List<(int, string, string)>(); + + foreach (var resource in asm.GetManifestResourceNames()) + { + var idx = resource.IndexOf(MigrationPrefix, StringComparison.Ordinal); + if (idx < 0 || !resource.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) + continue; + + var name = resource[(idx + MigrationPrefix.Length)..]; // e.g. "0001_init.sql" + var versionText = name.Split('_', 2)[0]; + if (!int.TryParse(versionText, out var version)) + continue; + + using var stream = asm.GetManifestResourceStream(resource)!; + using var reader = new StreamReader(stream); + migrations.Add((version, name, reader.ReadToEnd())); + } + + return migrations.OrderBy(m => m.Item1); + } +} diff --git a/src/Dispatch.Data/Dispatch.Data.csproj b/src/Dispatch.Data/Dispatch.Data.csproj new file mode 100644 index 00000000..a3d0c6ef --- /dev/null +++ b/src/Dispatch.Data/Dispatch.Data.csproj @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Dispatch.Data/Migrations/0001_init.sql b/src/Dispatch.Data/Migrations/0001_init.sql new file mode 100644 index 00000000..8c346d3b --- /dev/null +++ b/src/Dispatch.Data/Migrations/0001_init.sql @@ -0,0 +1,111 @@ +-- Dispatch schema v1 (spec §6.11, §7.6, §12.3). Runs as a single batch (no GO separators); +-- tables are created in FK-dependency order. Guarded by schema_version, so it runs once. + +CREATE TABLE config ( + [key] NVARCHAR(128) NOT NULL PRIMARY KEY, + value NVARCHAR(MAX) NOT NULL, + encrypted BIT NOT NULL DEFAULT 0, + updated_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() +); + +CREATE TABLE relays ( + id INT IDENTITY PRIMARY KEY, + name NVARCHAR(128) NOT NULL UNIQUE, + provider NVARCHAR(64) NOT NULL, + is_default BIT NOT NULL DEFAULT 0, + enabled BIT NOT NULL DEFAULT 1, + max_concurrency INT NOT NULL DEFAULT 4, + max_message_bytes INT NOT NULL DEFAULT 0, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + updated_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() +); +CREATE UNIQUE INDEX IX_relays_default ON relays (is_default) WHERE is_default = 1; + +CREATE TABLE routing_rules ( + id INT IDENTITY PRIMARY KEY, + priority INT NOT NULL UNIQUE, + name NVARCHAR(128) NOT NULL, + recipient_pattern NVARCHAR(256) NULL, + sender_pattern NVARCHAR(256) NULL, + relay_id INT NOT NULL REFERENCES relays(id), + enabled BIT NOT NULL DEFAULT 1, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() +); + +CREATE TABLE api_keys ( + id INT IDENTITY PRIMARY KEY, + key_id NVARCHAR(32) NOT NULL UNIQUE, + key_hash NVARCHAR(512) NOT NULL, + name NVARCHAR(256) NOT NULL, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + last_used_at DATETIME2 NULL, + message_count BIGINT NOT NULL DEFAULT 0, + revoked BIT NOT NULL DEFAULT 0, + revoked_at DATETIME2 NULL, + rate_limit_per_minute INT NOT NULL DEFAULT 100, + scope NVARCHAR(64) NOT NULL DEFAULT 'send' +); +CREATE INDEX IX_api_keys_key_id ON api_keys (key_id) WHERE revoked = 0; + +CREATE TABLE relay_counters ( + id INT IDENTITY PRIMARY KEY, + date DATE NOT NULL, + relay_id INT NOT NULL REFERENCES relays(id), + received BIGINT NOT NULL DEFAULT 0, + delivered BIGINT NOT NULL DEFAULT 0, + failed BIGINT NOT NULL DEFAULT 0, + retried BIGINT NOT NULL DEFAULT 0, + denied BIGINT NOT NULL DEFAULT 0, + CONSTRAINT UQ_relay_counters UNIQUE (date, relay_id) +); +CREATE INDEX IX_relay_counters_date ON relay_counters (date DESC); + +CREATE TABLE relay_log ( + id BIGINT IDENTITY PRIMARY KEY, + logged_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + spool_id NVARCHAR(64) NOT NULL, + event NVARCHAR(32) NOT NULL, + status NVARCHAR(16) NOT NULL, + retry_attempt INT NOT NULL DEFAULT 0, + from_address NVARCHAR(512) NOT NULL, + from_domain NVARCHAR(255) NOT NULL, + to_addresses NVARCHAR(MAX) NOT NULL, + to_domain NVARCHAR(255) NOT NULL, + subject NVARCHAR(998) NOT NULL, + size_bytes INT NOT NULL DEFAULT 0, + relay_id INT NULL REFERENCES relays(id), + relay_name NVARCHAR(128) NULL, + routing_rule_id INT NULL REFERENCES routing_rules(id), + routing_rule_name NVARCHAR(128) NULL, + routing_matched BIT NOT NULL DEFAULT 0, + provider NVARCHAR(64) NULL, + provider_message_id NVARCHAR(256) NULL, + provider_response NVARCHAR(MAX) NULL, + duration_ms INT NULL, + error NVARCHAR(MAX) NULL, + ingest_source NVARCHAR(16) NOT NULL DEFAULT 'SMTP', + source_ip NVARCHAR(64) NULL, + api_key_id INT NULL REFERENCES api_keys(id), + api_key_name NVARCHAR(256) NULL, + tags NVARCHAR(MAX) NULL +); +CREATE INDEX IX_relay_log_status_date ON relay_log (status, logged_at DESC) + INCLUDE (spool_id, from_address, from_domain, to_domain, subject, size_bytes, + relay_name, routing_rule_name, provider, duration_ms, ingest_source, retry_attempt); +CREATE INDEX IX_relay_log_from_domain ON relay_log (from_domain, logged_at DESC); +CREATE INDEX IX_relay_log_to_domain ON relay_log (to_domain, logged_at DESC); +CREATE INDEX IX_relay_log_source ON relay_log (ingest_source, logged_at DESC); +CREATE INDEX IX_relay_log_purge ON relay_log (logged_at); + +CREATE TABLE config_smtp_credentials ( + id INT IDENTITY PRIMARY KEY, + username NVARCHAR(256) NOT NULL UNIQUE, + password_hash NVARCHAR(512) NOT NULL, + created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + last_used_at DATETIME2 NULL +); + +-- Seed a single default relay in the "Unconfigured" state: until an administrator picks a provider, +-- Dispatch refuses to relay (mail is never silently delivered or discarded). +INSERT INTO relays (name, provider, is_default, enabled, max_concurrency) +VALUES (N'default', N'Unconfigured', 1, 1, 4); diff --git a/src/Dispatch.Data/Migrations/0002_relay_log_indexes.sql b/src/Dispatch.Data/Migrations/0002_relay_log_indexes.sql new file mode 100644 index 00000000..fcefab0f --- /dev/null +++ b/src/Dispatch.Data/Migrations/0002_relay_log_indexes.sql @@ -0,0 +1,10 @@ +-- Spec §6.11: backing indexes for the Message Log "filter by relay" and "filter by routing rule" +-- queries. Without these, those filters scan relay_log. Guarded so the migration is re-runnable. + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_relay_log_relay' AND object_id = OBJECT_ID('relay_log')) + CREATE INDEX IX_relay_log_relay ON relay_log (relay_id, logged_at DESC) + INCLUDE (status, event, spool_id, from_address, to_domain, subject, provider, duration_ms); + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_relay_log_rule' AND object_id = OBJECT_ID('relay_log')) + CREATE INDEX IX_relay_log_rule ON relay_log (routing_rule_id, logged_at DESC) + INCLUDE (status, event, spool_id, from_address, to_domain, subject, provider, duration_ms); diff --git a/src/Dispatch.Data/Migrations/0003_drop_unconfigured_default.sql b/src/Dispatch.Data/Migrations/0003_drop_unconfigured_default.sql new file mode 100644 index 00000000..3088bb73 --- /dev/null +++ b/src/Dispatch.Data/Migrations/0003_drop_unconfigured_default.sql @@ -0,0 +1,15 @@ +-- Onboarding change: don't ship a pre-seeded placeholder relay. +-- 0001 seeded a single 'default'/'Unconfigured' catch-all so the routing engine always had a fallback. +-- That confused first-run users (an empty relay you must go edit). Instead the first-run wizard creates +-- the first real relay, which becomes the catch-all automatically (SqlRelayRepository.CreateAsync). +-- +-- Remove the placeholder ONLY if it was never configured (no provider chosen for it in the config table, +-- keys 'relay:{id}:provider'), so we never delete a relay an operator has actually set up. Routing +-- degrades cleanly with no default: mail permanently fails with a clear "no provider configured" result +-- until the wizard/first relay is added. +DELETE FROM relays +WHERE provider = 'Unconfigured' AND is_default = 1 + AND NOT EXISTS ( + SELECT 1 FROM config c + WHERE c.[key] = N'relay:' + CAST(relays.id AS NVARCHAR(10)) + N':provider' + ); diff --git a/src/Dispatch.Data/Migrations/0004_relay_log_x_mailer_attachments.sql b/src/Dispatch.Data/Migrations/0004_relay_log_x_mailer_attachments.sql new file mode 100644 index 00000000..a17d2eee --- /dev/null +++ b/src/Dispatch.Data/Migrations/0004_relay_log_x_mailer_attachments.sql @@ -0,0 +1,9 @@ +-- Surface two more message attributes in the Message Log detail: the originating client (X-Mailer +-- header) and how many attachments the message carried. Captured at ingest and stored per relay_log row. +-- Guarded so the migration is re-runnable. + +IF COL_LENGTH('relay_log', 'x_mailer') IS NULL + ALTER TABLE relay_log ADD x_mailer NVARCHAR(256) NULL; + +IF COL_LENGTH('relay_log', 'attachment_count') IS NULL + ALTER TABLE relay_log ADD attachment_count INT NOT NULL DEFAULT 0; diff --git a/src/Dispatch.Data/Migrations/0005_relay_log_perf_indexes.sql b/src/Dispatch.Data/Migrations/0005_relay_log_perf_indexes.sql new file mode 100644 index 00000000..03a1dadc --- /dev/null +++ b/src/Dispatch.Data/Migrations/0005_relay_log_perf_indexes.sql @@ -0,0 +1,23 @@ +-- Performance tuning for relay_log (spec §19). Two access paths were unindexed and scanned the table: +-- +-- 1. Lookup by spool_id - used by the message-detail attempt history (GetByIdAsync), the by-spool +-- lookup (GetBySpoolIdAsync, also the Local Inbox "Show delivery log"), and the public by-spool +-- status endpoint. Both ascending (history) and descending (latest row) orderings are needed, so the +-- composite (spool_id, logged_at, id) serves either direction with a seek instead of a scan. +-- +-- 2. Per-API-key message list (RecentByApiKeyAsync / GET /api/v1/messages) filters api_key_id and orders +-- by logged_at DESC. A filtered index (api_key_id IS NOT NULL) keeps it tiny - the vast majority of +-- rows are SMTP ingest with a NULL api_key_id and are excluded from the index entirely. +-- +-- Other access paths are already covered: the unfiltered/date-range list orders by (logged_at DESC, id +-- DESC) and is served by IX_relay_log_purge (logged_at) via an ordered backward scan; status / source / +-- from_domain / to_domain / relay_id / routing_rule_id each have a dedicated (col, logged_at DESC) index. +-- subject/tag use leading-wildcard LIKE and are inherently non-sargable (would need full-text), so no +-- index is added for them. Guarded so the migration is re-runnable. + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_relay_log_spool_id' AND object_id = OBJECT_ID('relay_log')) + CREATE INDEX IX_relay_log_spool_id ON relay_log (spool_id, logged_at, id); + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_relay_log_api_key' AND object_id = OBJECT_ID('relay_log')) + CREATE INDEX IX_relay_log_api_key ON relay_log (api_key_id, logged_at DESC) + WHERE api_key_id IS NOT NULL; diff --git a/src/Dispatch.Data/Migrations/0006_audit_log.sql b/src/Dispatch.Data/Migrations/0006_audit_log.sql new file mode 100644 index 00000000..4c6e136e --- /dev/null +++ b/src/Dispatch.Data/Migrations/0006_audit_log.sql @@ -0,0 +1,23 @@ +-- Audit/security event log surfaced on the System Logs page (spec §13/§17). Records who did what +-- (auth, API-key changes, config changes) and unhandled server errors. `kind` is the coarse filter the +-- UI exposes ('audit' vs 'error'); `category`/`severity` add detail. Guarded so the migration re-runs. + +IF OBJECT_ID('audit_log', 'U') IS NULL +BEGIN + CREATE TABLE audit_log ( + id BIGINT IDENTITY PRIMARY KEY, + logged_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + kind NVARCHAR(16) NOT NULL, -- audit | error + category NVARCHAR(32) NOT NULL, -- Auth | ApiKey | Config | Error + event NVARCHAR(128) NOT NULL, + severity NVARCHAR(16) NOT NULL DEFAULT 'Info', -- Info | Notice | Warning | Error + actor NVARCHAR(128) NULL, + source_ip NVARCHAR(64) NULL, + detail NVARCHAR(MAX) NULL + ); + + -- Default listing order (newest first) + keyset tie-break. + CREATE INDEX IX_audit_log_at ON audit_log (logged_at DESC, id DESC); + -- The 'audit' vs 'error' filter. + CREATE INDEX IX_audit_log_kind ON audit_log (kind, logged_at DESC); +END diff --git a/src/Dispatch.Data/Migrations/0007_relay_counters_system_bucket.sql b/src/Dispatch.Data/Migrations/0007_relay_counters_system_bucket.sql new file mode 100644 index 00000000..3ea1579f --- /dev/null +++ b/src/Dispatch.Data/Migrations/0007_relay_counters_system_bucket.sql @@ -0,0 +1,17 @@ +-- relay_counters.relay_id was NOT NULL with a foreign key to relays(id). Connection-level events that +-- aren't tied to a relay - denials (counted under relay_id = 0) - could therefore never be inserted, so +-- they were silently dropped from the counters, and thus from /stats and the Reports page, even though the +-- relay_log row (Message Log) recorded them. Drop the FK so relay_id = 0 is a valid "no specific relay" +-- bucket. Aggregate summaries SUM across all rows (including 0); per-relay views join/filter to relay_id > 0, +-- so the denied bucket never appears as a phantom relay. +DECLARE @fk sysname; +SELECT @fk = fk.name +FROM sys.foreign_keys fk +WHERE fk.parent_object_id = OBJECT_ID('dbo.relay_counters') + AND fk.referenced_object_id = OBJECT_ID('dbo.relays'); +IF @fk IS NOT NULL +BEGIN + -- EXEC() can't contain function calls inline, so build the statement into a variable first. + DECLARE @sql nvarchar(max) = N'ALTER TABLE dbo.relay_counters DROP CONSTRAINT ' + QUOTENAME(@fk); + EXEC sp_executesql @sql; +END diff --git a/src/Dispatch.Data/Repositories/SqlApiKeyRepository.cs b/src/Dispatch.Data/Repositories/SqlApiKeyRepository.cs new file mode 100644 index 00000000..6ad58b26 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlApiKeyRepository.cs @@ -0,0 +1,120 @@ +using System.Security.Cryptography; +using Dapper; +using Dispatch.Core.ApiKeys; + +namespace Dispatch.Data.Repositories; + +/// +/// Manages API keys (spec §7.6–§7.7, §17.4): generates dsp_live_… keys with 256 bits of entropy, +/// stores only their bcrypt (cost 12) hash, and verifies in constant time to avoid a timing oracle. +/// +public sealed class SqlApiKeyRepository(SqlConnectionFactory factory) : IApiKeyRepository +{ + private const string Prefix = "dsp_live_"; + private const int KeyIdLength = 12; + private const int WorkFactor = 12; + + // A real bcrypt hash compared against when the key_id is unknown, so the timing matches the found path. + private static readonly string DummyHash = BCrypt.Net.BCrypt.HashPassword("dummy", WorkFactor); + + private const string SelectColumns = """ + id, key_id AS KeyId, key_hash AS KeyHash, name, created_at AS CreatedAt, last_used_at AS LastUsedAt, + message_count AS MessageCount, revoked, revoked_at AS RevokedAt, rate_limit_per_minute AS RateLimitPerMinute + """; + + public async Task CreateAsync(string name, int rateLimitPerMinute, CancellationToken ct = default) + { + var random = Base64Url(RandomNumberGenerator.GetBytes(32)); + var plaintext = Prefix + random; + var keyId = plaintext[..KeyIdLength]; + var hash = BCrypt.Net.BCrypt.HashPassword(plaintext, WorkFactor); + + const string insert = """ + INSERT INTO api_keys (key_id, key_hash, name, rate_limit_per_minute) + OUTPUT INSERTED.id, INSERTED.key_id AS KeyId, INSERTED.key_hash AS KeyHash, INSERTED.name, + INSERTED.created_at AS CreatedAt, INSERTED.last_used_at AS LastUsedAt, + INSERTED.message_count AS MessageCount, INSERTED.revoked, INSERTED.revoked_at AS RevokedAt, + INSERTED.rate_limit_per_minute AS RateLimitPerMinute + VALUES (@keyId, @hash, @name, @rateLimitPerMinute); + """; + + await using var cn = await factory.OpenAsync(ct); + var inserted = await cn.QuerySingleAsync(new CommandDefinition( + insert, new { keyId, hash, name, rateLimitPerMinute }, cancellationToken: ct)); + + return new ApiKeyCreated(inserted.ToApiKey(), plaintext); + } + + public async Task> ListAsync(bool includeRevoked = false, CancellationToken ct = default) + { + var where = includeRevoked ? "" : "WHERE revoked = 0"; + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync(new CommandDefinition( + $"SELECT {SelectColumns} FROM api_keys {where} ORDER BY created_at DESC", cancellationToken: ct)); + return rows.Select(r => r.ToApiKey()).ToList(); + } + + public async Task RevokeAsync(int id, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var affected = await cn.ExecuteAsync(new CommandDefinition( + "UPDATE api_keys SET revoked = 1, revoked_at = SYSUTCDATETIME() WHERE id = @id AND revoked = 0", + new { id }, cancellationToken: ct)); + return affected > 0; + } + + public async Task VerifyAsync(string rawKey, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(rawKey) || rawKey.Length < KeyIdLength || !rawKey.StartsWith(Prefix)) + { + BCrypt.Net.BCrypt.Verify(rawKey ?? "", DummyHash); // constant-time even on malformed input (verify the input, not a constant) + return null; + } + + var keyId = rawKey[..KeyIdLength]; + await using var cn = await factory.OpenAsync(ct); + var row = await cn.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {SelectColumns} FROM api_keys WHERE key_id = @keyId AND revoked = 0", + new { keyId }, cancellationToken: ct)); + + if (row is null) + { + BCrypt.Net.BCrypt.Verify(rawKey, DummyHash); // prevent timing-based key-id enumeration + return null; + } + + return BCrypt.Net.BCrypt.Verify(rawKey, row.KeyHash) ? row.ToApiKey() : null; + } + + public async Task RecordUsageAsync(int id, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + await cn.ExecuteAsync(new CommandDefinition( + "UPDATE api_keys SET last_used_at = SYSUTCDATETIME(), message_count = message_count + 1 WHERE id = @id", + new { id }, cancellationToken: ct)); + } + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private sealed class Row + { + public int Id { get; init; } + public string KeyId { get; init; } = ""; + public string KeyHash { get; init; } = ""; + public string Name { get; init; } = ""; + public DateTime CreatedAt { get; init; } + public DateTime? LastUsedAt { get; init; } + public long MessageCount { get; init; } + public bool Revoked { get; init; } + public DateTime? RevokedAt { get; init; } + public int RateLimitPerMinute { get; init; } + + public ApiKey ToApiKey() => new() + { + Id = Id, KeyId = KeyId, KeyHash = KeyHash, Name = Name, CreatedAt = CreatedAt, + LastUsedAt = LastUsedAt, MessageCount = MessageCount, Revoked = Revoked, + RevokedAt = RevokedAt, RateLimitPerMinute = RateLimitPerMinute, + }; + } +} diff --git a/src/Dispatch.Data/Repositories/SqlAuditLog.cs b/src/Dispatch.Data/Repositories/SqlAuditLog.cs new file mode 100644 index 00000000..439612c7 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlAuditLog.cs @@ -0,0 +1,142 @@ +using System.Data; +using System.Text; +using Dapper; +using Dispatch.Core.Audit; +using Microsoft.Extensions.Logging; + +namespace Dispatch.Data.Repositories; + +/// Append-only audit/security log (spec §17). Writes are best-effort: a logging failure is +/// swallowed (and warned) so it never breaks the audited action. +public sealed class SqlAuditLog(SqlConnectionFactory factory, ILogger log) : IAuditLog +{ + public async Task WriteAsync(string kind, string category, string @event, string severity, + string? actor, string? sourceIp, string? detail, CancellationToken ct = default) + { + const string sql = """ + INSERT INTO audit_log (kind, category, event, severity, actor, source_ip, detail) + VALUES (@Kind, @Category, @Event, @Severity, @Actor, @SourceIp, @Detail); + """; + try + { + await using var cn = await factory.OpenAsync(ct); + await cn.ExecuteAsync(new CommandDefinition(sql, new + { + Kind = Trunc(kind, 16), + Category = Trunc(category, 32), + Event = Trunc(@event, 128), + Severity = Trunc(severity, 16), + Actor = Trunc(actor, 128), + SourceIp = Trunc(sourceIp, 64), + Detail = detail, + }, cancellationToken: ct)); + } + catch (Exception ex) + { + log.LogWarning(ex, "Audit write failed ({Category}/{Event})", category, @event); + } + } + + public async Task QueryAsync(AuditFilter filter, CancellationToken ct = default) + { + var limit = Math.Clamp(filter.Limit, 1, 200); + var where = new StringBuilder("WHERE 1 = 1"); + var p = new DynamicParameters(); + p.Add("Limit", limit); + + if (!string.IsNullOrWhiteSpace(filter.Kind)) { where.Append(" AND kind = @Kind"); p.Add("Kind", filter.Kind); } + if (!string.IsNullOrWhiteSpace(filter.Category)) { where.Append(" AND category = @Category"); p.Add("Category", filter.Category); } + if (!string.IsNullOrWhiteSpace(filter.Severity)) { where.Append(" AND severity = @Severity"); p.Add("Severity", filter.Severity); } + if (!string.IsNullOrWhiteSpace(filter.Search)) + { + where.Append(" AND (event LIKE @S ESCAPE '\\' OR detail LIKE @S ESCAPE '\\' OR actor LIKE @S ESCAPE '\\' OR category LIKE @S ESCAPE '\\')"); + var esc = filter.Search.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_"); + p.Add("S", "%" + esc + "%"); + } + if (filter.Cursor is { } c) + { + where.Append(" AND (logged_at < @CursorAt OR (logged_at = @CursorAt AND id < @CursorId))"); + p.Add("CursorAt", c.LoggedAt, DbType.DateTime2); + p.Add("CursorId", c.Id); + } + + var sql = $""" + SELECT TOP (@Limit) + id AS Id, logged_at AS LoggedAt, kind AS Kind, category AS Category, event AS Event, + severity AS Severity, actor AS Actor, source_ip AS SourceIp, detail AS Detail + FROM audit_log + {where} + ORDER BY logged_at DESC, id DESC; + """; + + await using var cn = await factory.OpenAsync(ct); + var rows = (await cn.QueryAsync(new CommandDefinition(sql, p, cancellationToken: ct))).ToList(); + var next = rows.Count == limit ? new AuditCursor(rows[^1].LoggedAt, rows[^1].Id) : null; + return new AuditPage(rows, next); + } + + public async Task PurgeAsync(int generalRetentionDays, int securityRetentionDays, CancellationToken ct = default) + { + try + { + await using var cn = await factory.OpenAsync(ct); + var total = 0; + // Noisy security events (allow-list denials, SMTP auth failures) are kept shorter. + if (securityRetentionDays > 0) + total += await DeleteBatchedAsync(cn, + "DELETE TOP (@Batch) FROM audit_log WHERE category IN ('Access','SmtpAuth') AND logged_at < DATEADD(DAY, -@Days, SYSUTCDATETIME());", + securityRetentionDays, ct); + if (generalRetentionDays > 0) + total += await DeleteBatchedAsync(cn, + "DELETE TOP (@Batch) FROM audit_log WHERE logged_at < DATEADD(DAY, -@Days, SYSUTCDATETIME());", + generalRetentionDays, ct); + return total; + } + catch (Exception ex) + { + log.LogWarning(ex, "Audit log purge failed"); + return 0; + } + } + + public async Task ArchiveAndDeleteOldestAsync(int batch, Dispatch.Core.Maintenance.ArchiveRows archive, CancellationToken ct = default) + { + try + { + await using var cn = await factory.OpenAsync(ct); + var rows = (await cn.QueryAsync(new CommandDefinition( + "SELECT TOP (@batch) * FROM audit_log ORDER BY logged_at ASC, id ASC;", + new { batch }, cancellationToken: ct))).ToList(); + if (rows.Count == 0) return 0; + + // Archive before deleting; if archiving throws, keep the rows. + await archive(rows.Select(r => SqlLogMaintenance.ToRow((IDictionary)r)).ToList(), ct); + + var ids = rows.Select(r => Convert.ToInt64(((IDictionary)r)["id"])).ToArray(); + return await cn.ExecuteAsync(new CommandDefinition( + "DELETE FROM audit_log WHERE id IN @ids;", new { ids }, cancellationToken: ct)); + } + catch (Exception ex) + { + log.LogWarning(ex, "Audit archive-and-delete failed"); + return 0; + } + } + + private static async Task DeleteBatchedAsync(System.Data.Common.DbConnection cn, string sql, int days, CancellationToken ct) + { + const int batch = 1000; + var total = 0; + while (!ct.IsCancellationRequested) + { + var deleted = await cn.ExecuteAsync(new CommandDefinition(sql, new { Batch = batch, Days = days }, cancellationToken: ct)); + total += deleted; + if (deleted < batch) break; + await Task.Delay(100, ct); + } + return total; + } + + private static string? Trunc(string? value, int max) => + value is { Length: > 0 } && value.Length > max ? value[..max] : value; +} diff --git a/src/Dispatch.Data/Repositories/SqlConfigRepository.cs b/src/Dispatch.Data/Repositories/SqlConfigRepository.cs new file mode 100644 index 00000000..7e14d6a3 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlConfigRepository.cs @@ -0,0 +1,60 @@ +using Dapper; +using Dispatch.Core.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Dispatch.Data.Repositories; + +/// Key/value config store with transparent encryption for sensitive rows (spec §12.3, §19.5). +public sealed class SqlConfigRepository(SqlConnectionFactory factory, ILogger? logger = null) : IConfigRepository +{ + private readonly ILogger _log = logger ?? NullLogger.Instance; + + public async Task GetAsync(string key, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var row = await cn.QuerySingleOrDefaultAsync<(string Value, bool Encrypted)?>( + new CommandDefinition("SELECT value AS Value, encrypted AS Encrypted FROM config WHERE [key] = @key", + new { key }, cancellationToken: ct)); + if (row is null) return null; + return row.Value.Encrypted ? TryDecrypt(key, row.Value.Value) : row.Value.Value; + } + + public async Task SetAsync(string key, string value, bool encrypted = false, CancellationToken ct = default) + { + var stored = encrypted ? SecureConfig.Encrypt(value) : value; + const string sql = """ + MERGE config AS t + USING (VALUES (@key)) AS s([key]) ON t.[key] = s.[key] + WHEN MATCHED THEN UPDATE SET value = @stored, encrypted = @encrypted, updated_at = SYSUTCDATETIME() + WHEN NOT MATCHED THEN INSERT ([key], value, encrypted) VALUES (@key, @stored, @encrypted); + """; + await using var cn = await factory.OpenAsync(ct); + await cn.ExecuteAsync(new CommandDefinition(sql, new { key, stored, encrypted }, cancellationToken: ct)); + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync<(string Key, string Value, bool Encrypted, DateTime UpdatedAt)>( + new CommandDefinition("SELECT [key] AS [Key], value AS Value, encrypted AS Encrypted, updated_at AS UpdatedAt FROM config", + cancellationToken: ct)); + return rows + .Select(r => new ConfigEntry(r.Key, r.Encrypted ? TryDecrypt(r.Key, r.Value) ?? "" : r.Value, r.Encrypted, r.UpdatedAt)) + .ToList(); + } + + // A machine-key change (or corrupt value) shouldn't crash config reads - log and treat as unset. + private string? TryDecrypt(string key, string ciphertext) + { + try + { + return SecureConfig.Decrypt(ciphertext); + } + catch (Exception ex) + { + _log.LogError(ex, "Failed to decrypt config value for {Key} (machine key changed?); treating as unset", key); + return null; + } + } +} diff --git a/src/Dispatch.Data/Repositories/SqlCounterRepository.cs b/src/Dispatch.Data/Repositories/SqlCounterRepository.cs new file mode 100644 index 00000000..d057bdd0 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlCounterRepository.cs @@ -0,0 +1,129 @@ +using Dapper; +using Dispatch.Core.Counters; + +namespace Dispatch.Data.Repositories; + +/// Always-written daily aggregates via MERGE upsert (spec §6.11). Never on the hot path. +public sealed class SqlCounterRepository(SqlConnectionFactory factory) : ICounterRepository, ICounterReader +{ + public async Task IncrementAsync(int? relayId, CounterField field, CancellationToken ct = default) + { + // relay_id 0 is the "no specific relay" bucket for connection-level events (denials counted before + // routing). It's summed into the totals but excluded from per-relay views. Negative ids are invalid. + var rid = relayId ?? 0; + if (rid < 0) + return; + + var column = field switch + { + CounterField.Received => "received", + CounterField.Delivered => "delivered", + CounterField.Failed => "failed", + CounterField.Retried => "retried", + CounterField.Denied => "denied", + _ => throw new ArgumentOutOfRangeException(nameof(field)), + }; + + // Column name comes from a fixed enum mapping, never user input. + var sql = $""" + MERGE relay_counters AS t + USING (VALUES (CAST(SYSUTCDATETIME() AS DATE), @relayId)) AS s(date, relay_id) + ON t.date = s.date AND t.relay_id = s.relay_id + WHEN MATCHED THEN UPDATE SET {column} = {column} + 1 + WHEN NOT MATCHED THEN INSERT (date, relay_id, {column}) VALUES (s.date, s.relay_id, 1); + """; + + await using var cn = await factory.OpenAsync(ct); + await cn.ExecuteAsync(new CommandDefinition(sql, new { relayId = rid }, cancellationToken: ct)); + } + + public async Task GetTodayAsync(CancellationToken ct = default) + { + const string sql = """ + SELECT + ISNULL(SUM(received), 0) AS Received, + ISNULL(SUM(delivered), 0) AS Delivered, + ISNULL(SUM(failed), 0) AS Failed, + ISNULL(SUM(retried), 0) AS Retried, + ISNULL(SUM(denied), 0) AS Denied + FROM relay_counters + WHERE date = CAST(SYSUTCDATETIME() AS DATE); + """; + await using var cn = await factory.OpenAsync(ct); + return await cn.QuerySingleAsync(new CommandDefinition(sql, cancellationToken: ct)); + } + + public async Task> GetTodayByRelayAsync(CancellationToken ct = default) + { + const string sql = """ + SELECT relay_id AS RelayId, + ISNULL(SUM(received), 0) AS Received, + ISNULL(SUM(delivered), 0) AS Delivered, + ISNULL(SUM(failed), 0) AS Failed, + ISNULL(SUM(retried), 0) AS Retried, + ISNULL(SUM(denied), 0) AS Denied + FROM relay_counters + WHERE date = CAST(SYSUTCDATETIME() AS DATE) + AND relay_id > 0 -- exclude the relay_id 0 "no relay" bucket (denials) from per-relay views + GROUP BY relay_id; + """; + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync(new CommandDefinition(sql, cancellationToken: ct)); + return rows.ToList(); + } + + public async Task GetRangeTotalsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) + { + const string sql = """ + SELECT + ISNULL(SUM(received), 0) AS Received, + ISNULL(SUM(delivered), 0) AS Delivered, + ISNULL(SUM(failed), 0) AS Failed, + ISNULL(SUM(retried), 0) AS Retried, + ISNULL(SUM(denied), 0) AS Denied + FROM relay_counters + WHERE date >= @From AND date <= @To; + """; + await using var cn = await factory.OpenAsync(ct); + return await cn.QuerySingleAsync(new CommandDefinition(sql, new { From = fromUtc.Date, To = toUtc.Date }, cancellationToken: ct)); + } + + public async Task> GetDailyAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) + { + const string sql = """ + SELECT CONVERT(char(10), date, 23) AS Date, + ISNULL(SUM(received), 0) AS Received, + ISNULL(SUM(delivered), 0) AS Delivered, + ISNULL(SUM(failed), 0) AS Failed, + ISNULL(SUM(retried), 0) AS Retried, + ISNULL(SUM(denied), 0) AS Denied + FROM relay_counters + WHERE date >= @From AND date <= @To + GROUP BY date + ORDER BY date ASC; + """; + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync(new CommandDefinition(sql, new { From = fromUtc.Date, To = toUtc.Date }, cancellationToken: ct)); + return rows.ToList(); + } + + public async Task> GetRangeByRelayAsync(DateTime fromUtc, DateTime toUtc, CancellationToken ct = default) + { + const string sql = """ + SELECT c.relay_id AS RelayId, r.name AS RelayName, + ISNULL(SUM(c.received), 0) AS Received, + ISNULL(SUM(c.delivered), 0) AS Delivered, + ISNULL(SUM(c.failed), 0) AS Failed, + ISNULL(SUM(c.retried), 0) AS Retried, + ISNULL(SUM(c.denied), 0) AS Denied + FROM relay_counters c + JOIN relays r ON r.id = c.relay_id + WHERE c.date >= @From AND c.date <= @To + GROUP BY c.relay_id, r.name + ORDER BY SUM(c.received) DESC, SUM(c.delivered) DESC; + """; + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync(new CommandDefinition(sql, new { From = fromUtc.Date, To = toUtc.Date }, cancellationToken: ct)); + return rows.ToList(); + } +} diff --git a/src/Dispatch.Data/Repositories/SqlDatabaseHealth.cs b/src/Dispatch.Data/Repositories/SqlDatabaseHealth.cs new file mode 100644 index 00000000..a4b87155 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlDatabaseHealth.cs @@ -0,0 +1,28 @@ +using Dapper; +using Dispatch.Core.Logging; +using Microsoft.Data.SqlClient; + +namespace Dispatch.Data.Repositories; + +/// Probes the database with a short-budget SELECT 1; reports unreachable instead of hanging /health. +public sealed class SqlDatabaseHealth(SqlConnectionFactory factory) : IDatabaseHealth +{ + public async Task IsReachableAsync(CancellationToken ct = default) + { + // Cap the connect string's connect timeout so a down database fails fast. + var cs = new SqlConnectionStringBuilder(factory.ConnectionString) { ConnectTimeout = 2 }.ConnectionString; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromSeconds(3)); + try + { + await using var cn = new SqlConnection(cs); + await cn.OpenAsync(cts.Token); + await cn.ExecuteScalarAsync(new CommandDefinition("SELECT 1", cancellationToken: cts.Token)); + return true; + } + catch + { + return false; + } + } +} diff --git a/src/Dispatch.Data/Repositories/SqlLogMaintenance.cs b/src/Dispatch.Data/Repositories/SqlLogMaintenance.cs new file mode 100644 index 00000000..b1d07075 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlLogMaintenance.cs @@ -0,0 +1,76 @@ +using Dapper; +using Dispatch.Core.Logging; +using Dispatch.Core.Maintenance; + +namespace Dispatch.Data.Repositories; + +/// relay_log purge operations (spec §6.10). Batched deletes with pauses to avoid lock contention. +public sealed class SqlLogMaintenance(SqlConnectionFactory factory) : ILogMaintenance +{ + private const int BatchSize = 1000; + + public async Task PurgeByRetentionAsync(string @event, int retentionDays, CancellationToken ct = default) + { + const string sql = """ + DELETE TOP (@BatchSize) FROM relay_log + WHERE event = @event AND logged_at < DATEADD(DAY, -@retentionDays, SYSUTCDATETIME()); + """; + await using var cn = await factory.OpenAsync(ct); + + var total = 0; + while (!ct.IsCancellationRequested) + { + var deleted = await cn.ExecuteAsync(new CommandDefinition( + sql, new { BatchSize, @event, retentionDays }, cancellationToken: ct)); + total += deleted; + if (deleted < BatchSize) break; + await Task.Delay(100, ct); // breathe between batches + } + return total; + } + + public async Task GetDatabaseSizeBytesAsync(CancellationToken ct = default) + { + const string sql = "SELECT CAST(SUM(size) AS BIGINT) * 8 * 1024 FROM sys.database_files WHERE type = 0;"; + await using var cn = await factory.OpenAsync(ct); + return await cn.ExecuteScalarAsync(new CommandDefinition(sql, cancellationToken: ct)); + } + + public async Task GetDatabaseUsedBytesAsync(CancellationToken ct = default) + { + // Used pages (not allocated file size) so the size-pressure loop terminates: this value drops as + // rows are deleted, whereas sys.database_files.size never shrinks on DELETE. + const string sql = "SELECT CAST(SUM(CAST(FILEPROPERTY(name, 'SpaceUsed') AS BIGINT)) AS BIGINT) * 8 * 1024 FROM sys.database_files WHERE type = 0;"; + await using var cn = await factory.OpenAsync(ct); + return await cn.ExecuteScalarAsync(new CommandDefinition(sql, cancellationToken: ct)); + } + + public async Task IsSizeCappedEditionAsync(CancellationToken ct = default) + { + // EngineEdition 4 = Express (the only edition with the 10 GB per-database data-file cap). 2/3 = + // Standard/Enterprise, 5/8 = Azure SQL DB/MI - none capped, so size-pressure must not run there. + const string sql = "SELECT CAST(SERVERPROPERTY('EngineEdition') AS INT);"; + await using var cn = await factory.OpenAsync(ct); + return await cn.ExecuteScalarAsync(new CommandDefinition(sql, cancellationToken: ct)) == 4; + } + + public async Task ArchiveAndDeleteOldestRelayLogAsync(int batch, ArchiveRows archive, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var rows = (await cn.QueryAsync(new CommandDefinition( + "SELECT TOP (@batch) * FROM relay_log ORDER BY logged_at ASC, id ASC;", + new { batch }, cancellationToken: ct))).ToList(); + if (rows.Count == 0) return 0; + + // Archive first; if that throws, the rows are NOT deleted (the safety net must not lose data). + await archive(rows.Select(r => ToRow((IDictionary)r)).ToList(), ct); + + var ids = rows.Select(r => Convert.ToInt64(((IDictionary)r)["id"])).ToArray(); + return await cn.ExecuteAsync(new CommandDefinition( + "DELETE FROM relay_log WHERE id IN @ids;", new { ids }, cancellationToken: ct)); + } + + // Materializes a Dapper row into a nullable-friendly read-only dictionary for archiving/serialization. + internal static IReadOnlyDictionary ToRow(IDictionary row) => + row.ToDictionary(kv => kv.Key, kv => (object?)kv.Value); +} diff --git a/src/Dispatch.Data/Repositories/SqlLogRepository.cs b/src/Dispatch.Data/Repositories/SqlLogRepository.cs new file mode 100644 index 00000000..7cfb0845 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlLogRepository.cs @@ -0,0 +1,60 @@ +using System.Text.Json; +using Dapper; +using Dispatch.Core.Logging; + +namespace Dispatch.Data.Repositories; + +/// Inserts relay_log rows (spec §6.11). The after-the-fact event history. +public sealed class SqlLogRepository(SqlConnectionFactory factory) : ILogRepository +{ + private const string Sql = """ + INSERT INTO relay_log + (spool_id, event, status, retry_attempt, from_address, from_domain, to_addresses, to_domain, + subject, size_bytes, relay_id, relay_name, routing_rule_id, routing_rule_name, routing_matched, + provider, provider_message_id, provider_response, duration_ms, error, ingest_source, source_ip, + api_key_id, api_key_name, tags, x_mailer, attachment_count) + VALUES + (@SpoolId, @Event, @Status, @RetryAttempt, @FromAddress, @FromDomain, @ToAddresses, @ToDomain, + @Subject, @SizeBytes, @RelayId, @RelayName, @RoutingRuleId, @RoutingRuleName, @RoutingMatched, + @Provider, @ProviderMessageId, @ProviderResponse, @DurationMs, @Error, @IngestSource, @SourceIp, + @ApiKeyId, @ApiKeyName, @Tags, @XMailer, @AttachmentCount); + """; + + public async Task InsertAsync(RelayLogEntry entry, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + await cn.ExecuteAsync(new CommandDefinition(Sql, new + { + entry.SpoolId, + entry.Event, + entry.Status, + entry.RetryAttempt, + FromAddress = Trunc(entry.FromAddress, 512), + FromDomain = Trunc(entry.FromDomain, 255), + ToAddresses = JsonSerializer.Serialize(entry.ToAddresses), + ToDomain = Trunc(entry.ToDomain, 255), + Subject = Trunc(entry.Subject ?? "", 998), + entry.SizeBytes, + entry.RelayId, + RelayName = Trunc(entry.RelayName, 128), + entry.RoutingRuleId, + RoutingRuleName = Trunc(entry.RoutingRuleName, 128), + entry.RoutingMatched, + Provider = Trunc(entry.Provider, 64), + ProviderMessageId = Trunc(entry.ProviderMessageId, 256), + entry.ProviderResponse, + entry.DurationMs, + entry.Error, + IngestSource = Trunc(entry.IngestSource, 16), + SourceIp = Trunc(entry.SourceIp, 64), + entry.ApiKeyId, + ApiKeyName = Trunc(entry.ApiKeyName, 256), + Tags = entry.Tags is { Count: > 0 } ? JsonSerializer.Serialize(entry.Tags) : null, + XMailer = Trunc(entry.XMailer, 256), + entry.AttachmentCount, + }, cancellationToken: ct)); + } + + private static string? Trunc(string? value, int max) => + value is { Length: > 0 } && value.Length > max ? value[..max] : value; +} diff --git a/src/Dispatch.Data/Repositories/SqlLoggingSettings.cs b/src/Dispatch.Data/Repositories/SqlLoggingSettings.cs new file mode 100644 index 00000000..e300dff8 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlLoggingSettings.cs @@ -0,0 +1,43 @@ +using Dispatch.Core.Configuration; +using Dispatch.Core.Logging; + +namespace Dispatch.Data.Repositories; + +/// relay_log suppression toggles from the SQL config table, cached briefly (spec §12.3). +public sealed class SqlLoggingSettings(IConfigRepository config) : ILoggingSettings +{ + public const string LogDeliveredKey = "logging.log_delivered"; + public const string LogRetryingKey = "logging.log_retrying"; + public const string LogDeniedKey = "logging.log_denied"; + + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(10); + private readonly Lock _lock = new(); + private (bool Delivered, bool Retrying, bool Denied) _cache = (true, true, true); + private DateTime _cachedAtUtc = DateTime.MinValue; + + public async ValueTask LogDeliveredAsync(CancellationToken ct = default) => (await GetAsync(ct)).Delivered; + public async ValueTask LogRetryingAsync(CancellationToken ct = default) => (await GetAsync(ct)).Retrying; + public async ValueTask LogDeniedAsync(CancellationToken ct = default) => (await GetAsync(ct)).Denied; + + private async ValueTask<(bool Delivered, bool Retrying, bool Denied)> GetAsync(CancellationToken ct) + { + lock (_lock) + { + if (DateTime.UtcNow - _cachedAtUtc < CacheTtl) return _cache; + } + + var snapshot = ( + await ReadBoolAsync(LogDeliveredKey, ct), + await ReadBoolAsync(LogRetryingKey, ct), + await ReadBoolAsync(LogDeniedKey, ct)); + + lock (_lock) { _cache = snapshot; _cachedAtUtc = DateTime.UtcNow; } + return snapshot; + } + + private async ValueTask ReadBoolAsync(string key, CancellationToken ct) + { + var value = await config.GetAsync(key, ct); + return value is null || !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase); // default on + } +} diff --git a/src/Dispatch.Data/Repositories/SqlMessageLogQuery.cs b/src/Dispatch.Data/Repositories/SqlMessageLogQuery.cs new file mode 100644 index 00000000..e251c8cc --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlMessageLogQuery.cs @@ -0,0 +1,259 @@ +using System.Data; +using System.Text; +using System.Text.Json; +using Dapper; +using Dispatch.Core.Logging; + +namespace Dispatch.Data.Repositories; + +/// +/// Keyset-paginated reads of relay_log for the Message Log (spec §9.2, §19). No COUNT(*), +/// no OFFSET. The WHERE clause is built by appending fixed fragments; every value is a Dapper +/// parameter, never interpolated - verified against SQL-injection payloads in the tests (§17). +/// +public sealed class SqlMessageLogQuery(SqlConnectionFactory factory) : IMessageLogQuery +{ + public async Task QueryAsync(MessageLogFilter filter, CancellationToken ct = default) + { + var limit = Math.Clamp(filter.Limit, 1, 200); + var where = new StringBuilder("WHERE 1 = 1"); + var p = new DynamicParameters(); + p.Add("Limit", limit); + AppendFilters(where, p, filter); + + if (filter.Cursor is { } cursor) + { + where.Append(" AND (logged_at < @CursorLoggedAt OR (logged_at = @CursorLoggedAt AND id < @CursorId))"); + p.Add("CursorLoggedAt", cursor.LoggedAt, DbType.DateTime2); + p.Add("CursorId", cursor.Id); + } + + var sql = $""" + SELECT TOP (@Limit) + {RowColumns} + FROM relay_log + {where} + ORDER BY logged_at DESC, id DESC; + """; + + await using var cn = await factory.OpenAsync(ct); + var rows = (await cn.QueryAsync(new CommandDefinition(sql, p, cancellationToken: ct))).ToList(); + + MessageLogCursor? next = rows.Count == limit + ? new MessageLogCursor(rows[^1].LoggedAt, rows[^1].Id) + : null; + + return new MessageLogPage(rows, next); + } + + public async Task PageAsync(MessageLogFilter filter, int offset, CancellationToken ct = default) + { + var limit = Math.Clamp(filter.Limit, 1, 200); + offset = Math.Max(0, offset); + var where = new StringBuilder("WHERE 1 = 1"); + var p = new DynamicParameters(); + AppendFilters(where, p, filter); + p.Add("Offset", offset); + p.Add("Limit", limit); + + // The list shows ONE row per message. relay_log holds a row per lifecycle event (a Retrying row per + // failed attempt, then a terminal Delivered/Failed), so collapse by spool_id and keep only the latest + // event as the message's current state. Connection-level rows (denials, pre-DATA failures) have no + // spool_id - each stays its own row (grouped by id). The full per-attempt history is in the detail view. + var sql = $""" + SELECT COUNT(*) FROM (SELECT DISTINCT {GroupKey} AS grp FROM relay_log {where}) g; + SELECT {RowColumns} + FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY {GroupKey} ORDER BY logged_at DESC, id DESC) AS rn + FROM relay_log + {where} + ) t + WHERE rn = 1 + ORDER BY logged_at DESC, id DESC + OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY; + """; + + await using var cn = await factory.OpenAsync(ct); + await using var multi = await cn.QueryMultipleAsync(new CommandDefinition(sql, p, cancellationToken: ct)); + var total = await multi.ReadSingleAsync(); + var rows = (await multi.ReadAsync()).ToList(); + return new MessageLogPaged(rows, total); + } + + private const string RowColumns = """ + id AS Id, logged_at AS LoggedAt, event AS Event, status AS Status, spool_id AS SpoolId, + from_address AS FromAddress, to_domain AS ToDomain, to_addresses AS ToAddressesJson, + subject AS Subject, relay_name AS RelayName, + provider AS Provider, duration_ms AS DurationMs, size_bytes AS SizeBytes, + ingest_source AS IngestSource, retry_attempt AS RetryAttempt, error AS Error + """; + + // Collapse a message's lifecycle rows (Retrying×N → terminal Delivered/Failed) into one group keyed by + // spool_id. Connection-level rows with no spool_id (denials, pre-DATA failures) each get their own group. + private const string GroupKey = "CASE WHEN spool_id IS NULL OR spool_id = '' THEN CONCAT('id:', id) ELSE spool_id END"; + + // Shared filter clauses (no cursor/paging). Every user value is a parameter - never interpolated. + private static void AppendFilters(StringBuilder where, DynamicParameters p, MessageLogFilter filter) + { + // Bind dates as datetime2 to match the column precision. + if (filter.FromUtc is { } from) { where.Append(" AND logged_at >= @FromUtc"); p.Add("FromUtc", from, DbType.DateTime2); } + if (filter.ToUtc is { } to) { where.Append(" AND logged_at < @ToUtc"); p.Add("ToUtc", to, DbType.DateTime2); } + if (filter.Statuses is { Length: > 0 } s) { where.Append(" AND status IN @Statuses"); p.Add("Statuses", s); } + if (filter.Events is { Length: > 0 } ev) { where.Append(" AND event IN @Events"); p.Add("Events", ev); } + if (!string.IsNullOrWhiteSpace(filter.IngestSource)) { where.Append(" AND ingest_source = @IngestSource"); p.Add("IngestSource", filter.IngestSource); } + if (!string.IsNullOrWhiteSpace(filter.FromDomain)) { where.Append(" AND from_domain = @FromDomain"); p.Add("FromDomain", filter.FromDomain); } + if (!string.IsNullOrWhiteSpace(filter.ToDomain)) { where.Append(" AND to_domain = @ToDomain"); p.Add("ToDomain", filter.ToDomain); } + if (!string.IsNullOrWhiteSpace(filter.RelayName)) { where.Append(" AND relay_name = @RelayName"); p.Add("RelayName", filter.RelayName); } + if (!string.IsNullOrWhiteSpace(filter.RoutingRuleName)) { where.Append(" AND routing_rule_name = @RoutingRuleName"); p.Add("RoutingRuleName", filter.RoutingRuleName); } + // Subject substring match. Escape LIKE wildcards in the user value so they're treated literally. + if (!string.IsNullOrWhiteSpace(filter.Subject)) + { + where.Append(" AND subject LIKE @SubjectPattern ESCAPE '\\'"); + p.Add("SubjectPattern", "%" + EscapeLike(filter.Subject) + "%"); + } + if (filter.ApiKeyId is { } apiKeyId) { where.Append(" AND api_key_id = @ApiKeyId"); p.Add("ApiKeyId", apiKeyId); } + // Tag: tags is a JSON array string; match %"tag"% with the value as a parameter. Escape LIKE wildcards + // so a tag containing % or _ matches literally (parameterised, so never an injection risk regardless). + if (!string.IsNullOrWhiteSpace(filter.Tag)) + { + where.Append(" AND tags LIKE @TagPattern ESCAPE '\\'"); + p.Add("TagPattern", "%\"" + EscapeLike(filter.Tag) + "\"%"); + } + } + + // Escapes the SQL LIKE metacharacters (\, %, _) so a user value is matched literally (used with ESCAPE '\'). + private static string EscapeLike(string s) => s.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_"); + + public async Task GetBySpoolIdAsync(string spoolId, int? apiKeyId, CancellationToken ct = default) + { + const string sql = """ + SELECT TOP 1 + id AS Id, logged_at AS LoggedAt, event AS Event, status AS Status, spool_id AS SpoolId, + from_address AS FromAddress, to_domain AS ToDomain, subject AS Subject, relay_name AS RelayName, + provider AS Provider, duration_ms AS DurationMs, size_bytes AS SizeBytes, + ingest_source AS IngestSource, retry_attempt AS RetryAttempt, error AS Error + FROM relay_log + WHERE spool_id = @spoolId AND (@apiKeyId IS NULL OR api_key_id = @apiKeyId) + ORDER BY logged_at DESC, id DESC; + """; + await using var cn = await factory.OpenAsync(ct); + return await cn.QuerySingleOrDefaultAsync( + new CommandDefinition(sql, new { spoolId, apiKeyId }, cancellationToken: ct)); + } + + public async Task> RecentByApiKeyAsync( + int apiKeyId, int limit, string[]? statuses, CancellationToken ct = default) + { + limit = Math.Clamp(limit, 1, 200); + var where = new StringBuilder("WHERE api_key_id = @ApiKeyId"); + var p = new DynamicParameters(); + p.Add("ApiKeyId", apiKeyId); + p.Add("Limit", limit); + if (statuses is { Length: > 0 }) { where.Append(" AND status IN @Statuses"); p.Add("Statuses", statuses); } + + // One row per message (latest event), matching the dashboard list - see GroupKey. + var sql = $""" + SELECT TOP (@Limit) {RowColumns} + FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY {GroupKey} ORDER BY logged_at DESC, id DESC) AS rn + FROM relay_log + {where} + ) t + WHERE rn = 1 + ORDER BY logged_at DESC, id DESC; + """; + await using var cn = await factory.OpenAsync(ct); + return (await cn.QueryAsync(new CommandDefinition(sql, p, cancellationToken: ct))).ToList(); + } + + public async Task GetByIdAsync(long id, CancellationToken ct = default) + { + const string sql = """ + SELECT TOP 1 + id AS Id, logged_at AS LoggedAt, event AS Event, status AS Status, spool_id AS SpoolId, + retry_attempt AS RetryAttempt, from_address AS FromAddress, from_domain AS FromDomain, + to_addresses AS ToAddressesJson, to_domain AS ToDomain, subject AS Subject, size_bytes AS SizeBytes, + relay_name AS RelayName, routing_rule_name AS RoutingRuleName, routing_matched AS RoutingMatched, + provider AS Provider, provider_message_id AS ProviderMessageId, provider_response AS ProviderResponse, + duration_ms AS DurationMs, error AS Error, ingest_source AS IngestSource, source_ip AS SourceIp, + api_key_name AS ApiKeyName, tags AS TagsJson, x_mailer AS XMailer, attachment_count AS AttachmentCount + FROM relay_log + WHERE id = @id; + """; + await using var cn = await factory.OpenAsync(ct); + var raw = await cn.QuerySingleOrDefaultAsync( + new CommandDefinition(sql, new { id }, cancellationToken: ct)); + if (raw is null) return null; + + // Retry/attempt timeline (spec §9.2): all relay_log rows sharing this spool id, oldest first. + // Denied/pre-DATA rows have an empty spool id, so fall back to this single row as the history. + IReadOnlyList history; + if (string.IsNullOrEmpty(raw.SpoolId)) + { + history = [new MessageLogAttempt(raw.LoggedAt, raw.Event, raw.Status, raw.RetryAttempt, raw.Provider, raw.DurationMs, raw.Error)]; + } + else + { + const string historySql = """ + SELECT logged_at AS LoggedAt, event AS Event, status AS Status, retry_attempt AS RetryAttempt, + provider AS Provider, duration_ms AS DurationMs, error AS Error + FROM relay_log + WHERE spool_id = @spoolId + ORDER BY logged_at ASC, id ASC; + """; + history = (await cn.QueryAsync( + new CommandDefinition(historySql, new { spoolId = raw.SpoolId }, cancellationToken: ct))).ToList(); + } + return raw.ToDetail(history); + } + + /// Flat projection - the JSON array columns are deserialised into string[] by . + private sealed class DetailRow + { + public long Id { get; init; } + public DateTime LoggedAt { get; init; } + public string Event { get; init; } = ""; + public string Status { get; init; } = ""; + public string SpoolId { get; init; } = ""; + public int RetryAttempt { get; init; } + public string FromAddress { get; init; } = ""; + public string FromDomain { get; init; } = ""; + public string? ToAddressesJson { get; init; } + public string ToDomain { get; init; } = ""; + public string? Subject { get; init; } + public int SizeBytes { get; init; } + public string? RelayName { get; init; } + public string? RoutingRuleName { get; init; } + public bool RoutingMatched { get; init; } + public string? Provider { get; init; } + public string? ProviderMessageId { get; init; } + public string? ProviderResponse { get; init; } + public int? DurationMs { get; init; } + public string? Error { get; init; } + public string IngestSource { get; init; } = ""; + public string? SourceIp { get; init; } + public string? ApiKeyName { get; init; } + public string? TagsJson { get; init; } + public string? XMailer { get; init; } + public int AttachmentCount { get; init; } + + public MessageLogDetail ToDetail(IReadOnlyList history) => new() + { + Id = Id, LoggedAt = LoggedAt, Event = Event, Status = Status, SpoolId = SpoolId, + RetryAttempt = RetryAttempt, FromAddress = FromAddress, FromDomain = FromDomain, + ToAddresses = ParseJsonArray(ToAddressesJson), ToDomain = ToDomain, Subject = Subject, + SizeBytes = SizeBytes, RelayName = RelayName, RoutingRuleName = RoutingRuleName, + RoutingMatched = RoutingMatched, Provider = Provider, ProviderMessageId = ProviderMessageId, + ProviderResponse = ProviderResponse, DurationMs = DurationMs, Error = Error, + IngestSource = IngestSource, SourceIp = SourceIp, ApiKeyName = ApiKeyName, + Tags = ParseJsonArray(TagsJson), XMailer = XMailer, AttachmentCount = AttachmentCount, History = history, + }; + } + + private static IReadOnlyList ParseJsonArray(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return []; + try { return JsonSerializer.Deserialize(json) ?? []; } + catch (JsonException) { return []; } + } +} diff --git a/src/Dispatch.Data/Repositories/SqlPurgeSettings.cs b/src/Dispatch.Data/Repositories/SqlPurgeSettings.cs new file mode 100644 index 00000000..1eb28665 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlPurgeSettings.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using Dispatch.Core.Configuration; +using Microsoft.Extensions.Options; + +namespace Dispatch.Data.Repositories; + +/// +/// Retention + auto-purge thresholds from the SQL config table (spec §12.3 purge.*), cached briefly so +/// the size-pressure loop doesn't re-query SQL. Falls back to the appsettings +/// defaults when a key is unset (spec §12.5 fallback). +/// +public sealed class SqlPurgeSettings(IConfigRepository config, IOptions defaults) : IPurgeSettings +{ + public const string LogDeliveredRetentionDaysKey = "purge.log_delivered_retention_days"; + public const string LogFailedRetentionDaysKey = "purge.log_failed_retention_days"; + public const string SizeTriggerGbKey = "purge.size_trigger_gb"; + public const string SizeTargetGbKey = "purge.size_target_gb"; + public const string SpoolFailedRetentionDaysKey = "purge.spool_failed_retention_days"; + public const string CapturedRetentionDaysKey = "purge.captured_retention_days"; + public const string AuditRetentionDaysKey = "purge.audit_retention_days"; + public const string AuditSecurityRetentionDaysKey = "purge.audit_security_retention_days"; + public const string ArchiveRetentionDaysKey = "purge.archive_retention_days"; + + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(10); + private readonly PurgeOptions _defaults = defaults.Value; + private readonly Lock _lock = new(); + private PurgeOptions? _cache; + private DateTime _cachedAtUtc = DateTime.MinValue; + + public async ValueTask GetAsync(CancellationToken ct = default) + { + lock (_lock) + { + if (_cache is not null && DateTime.UtcNow - _cachedAtUtc < CacheTtl) return _cache; + } + + // Only the spec-listed keys are SQL-overridable; the rest keep their appsettings defaults. + var snapshot = new PurgeOptions + { + Enabled = _defaults.Enabled, + ScheduleIntervalHours = _defaults.ScheduleIntervalHours, + SpoolFailedRetentionDays = await ReadIntAsync(SpoolFailedRetentionDaysKey, _defaults.SpoolFailedRetentionDays, ct), + CapturedRetentionDays = await ReadIntAsync(CapturedRetentionDaysKey, _defaults.CapturedRetentionDays, ct), + AuditRetentionDays = await ReadIntAsync(AuditRetentionDaysKey, _defaults.AuditRetentionDays, ct), + AuditSecurityRetentionDays = await ReadIntAsync(AuditSecurityRetentionDaysKey, _defaults.AuditSecurityRetentionDays, ct), + ArchiveRetentionDays = await ReadIntAsync(ArchiveRetentionDaysKey, _defaults.ArchiveRetentionDays, ct), + Log = new PurgeOptions.LogRetention + { + DeliveredRetentionDays = await ReadIntAsync(LogDeliveredRetentionDaysKey, _defaults.Log.DeliveredRetentionDays, ct), + FailedRetentionDays = await ReadIntAsync(LogFailedRetentionDaysKey, _defaults.Log.FailedRetentionDays, ct), + RetryingRetentionDays = _defaults.Log.RetryingRetentionDays, + TestSentRetentionDays = _defaults.Log.TestSentRetentionDays, + }, + SizePressure = new PurgeOptions.SizePressureOptions + { + TriggerGb = await ReadDoubleAsync(SizeTriggerGbKey, _defaults.SizePressure.TriggerGb, ct), + TargetGb = await ReadDoubleAsync(SizeTargetGbKey, _defaults.SizePressure.TargetGb, ct), + }, + }; + + lock (_lock) { _cache = snapshot; _cachedAtUtc = DateTime.UtcNow; } + return snapshot; + } + + private async ValueTask ReadIntAsync(string key, int fallback, CancellationToken ct) + { + var raw = await config.GetAsync(key, ct); + return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : fallback; + } + + private async ValueTask ReadDoubleAsync(string key, double fallback, CancellationToken ct) + { + var raw = await config.GetAsync(key, ct); + return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : fallback; + } +} diff --git a/src/Dispatch.Data/Repositories/SqlRelayRepository.cs b/src/Dispatch.Data/Repositories/SqlRelayRepository.cs new file mode 100644 index 00000000..8cba191b --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlRelayRepository.cs @@ -0,0 +1,160 @@ +using Dapper; +using Dispatch.Core.Providers; +using Dispatch.Core.Relays; + +namespace Dispatch.Data.Repositories; + +/// +/// Reads/writes the relays table with a short TTL cache on the default relay so the dispatch hot +/// path avoids a SQL query per message (spec §10.2, §19.7). Writes invalidate the cache. +/// +public sealed class SqlRelayRepository(SqlConnectionFactory factory) : IRelayRepository +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(5); + private const string SelectColumns = + "id, name, provider, is_default AS IsDefault, enabled, max_concurrency AS MaxConcurrency, max_message_bytes AS MaxMessageBytes"; + private const string InsertedColumns = + "INSERTED.id, INSERTED.name, INSERTED.provider, INSERTED.is_default AS IsDefault, INSERTED.enabled, INSERTED.max_concurrency AS MaxConcurrency, INSERTED.max_message_bytes AS MaxMessageBytes"; + + private readonly Lock _lock = new(); + private RelayRecord? _cachedDefault; + private DateTime _cachedAtUtc = DateTime.MinValue; + + public async Task GetDefaultAsync(CancellationToken ct = default) + { + lock (_lock) + { + if (_cachedDefault is not null && DateTime.UtcNow - _cachedAtUtc < CacheTtl) + return _cachedDefault; + } + + await using var cn = await factory.OpenAsync(ct); + var row = await cn.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT TOP 1 {SelectColumns} FROM relays WHERE is_default = 1 AND enabled = 1", cancellationToken: ct)); + + var record = row?.ToRecord(); + lock (_lock) { _cachedDefault = record; _cachedAtUtc = DateTime.UtcNow; } + return record; + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync(new CommandDefinition( + $"SELECT {SelectColumns} FROM relays ORDER BY id", cancellationToken: ct)); + return rows.Select(r => r.ToRecord()).ToList(); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var row = await cn.QuerySingleOrDefaultAsync(new CommandDefinition( + $"SELECT {SelectColumns} FROM relays WHERE id = @id", new { id }, cancellationToken: ct)); + return row?.ToRecord(); + } + + public async Task CreateAsync( + string name, RelayProviderType provider, int maxConcurrency, long maxMessageBytes, CancellationToken ct = default) + { + // The first relay created becomes the catch-all automatically (no pre-seeded placeholder), so a + // single-provider setup "just works" with no extra step. Subsequent relays are non-default. + const string sql = $""" + INSERT INTO relays (name, provider, max_concurrency, max_message_bytes, is_default) + OUTPUT {InsertedColumns} + VALUES (@name, @provider, @maxConcurrency, @maxMessageBytes, + CASE WHEN EXISTS (SELECT 1 FROM relays WHERE is_default = 1) THEN 0 ELSE 1 END); + """; + await using var cn = await factory.OpenAsync(ct); + var row = await cn.QuerySingleAsync(new CommandDefinition( + sql, new { name, provider = provider.ToString(), maxConcurrency, maxMessageBytes }, cancellationToken: ct)); + InvalidateCache(); + return row.ToRecord(); + } + + public async Task UpdateAsync( + int id, string name, RelayProviderType provider, bool enabled, int maxConcurrency, long maxMessageBytes, CancellationToken ct = default) + { + const string sql = """ + UPDATE relays SET name = @name, provider = @provider, enabled = @enabled, max_concurrency = @maxConcurrency, + max_message_bytes = @maxMessageBytes, updated_at = SYSUTCDATETIME() + WHERE id = @id; + """; + await using var cn = await factory.OpenAsync(ct); + var n = await cn.ExecuteAsync(new CommandDefinition( + sql, new { id, name, provider = provider.ToString(), enabled, maxConcurrency, maxMessageBytes }, cancellationToken: ct)); + InvalidateCache(); + return n > 0; + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + await using var tx = await cn.BeginTransactionAsync(ct); + try + { + // Preserve log history (relay_name is denormalised) but clear the FK; drop counters + credentials. + await cn.ExecuteAsync(new CommandDefinition("UPDATE relay_log SET relay_id = NULL WHERE relay_id = @id", new { id }, tx, cancellationToken: ct)); + await cn.ExecuteAsync(new CommandDefinition("DELETE FROM relay_counters WHERE relay_id = @id", new { id }, tx, cancellationToken: ct)); + await cn.ExecuteAsync(new CommandDefinition("DELETE FROM config WHERE [key] LIKE @prefix", new { prefix = $"relay:{id}:%" }, tx, cancellationToken: ct)); + var n = await cn.ExecuteAsync(new CommandDefinition("DELETE FROM relays WHERE id = @id AND is_default = 0", new { id }, tx, cancellationToken: ct)); + await tx.CommitAsync(ct); + InvalidateCache(); + return n > 0; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } + + public async Task SetDefaultAsync(int id, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + await using var tx = await cn.BeginTransactionAsync(ct); + try + { + var exists = await cn.ExecuteScalarAsync(new CommandDefinition( + "SELECT 1 FROM relays WHERE id = @id", new { id }, tx, cancellationToken: ct)); + if (exists is null) { await tx.RollbackAsync(ct); return false; } + + await cn.ExecuteAsync(new CommandDefinition("UPDATE relays SET is_default = 0 WHERE is_default = 1", transaction: tx, cancellationToken: ct)); + await cn.ExecuteAsync(new CommandDefinition("UPDATE relays SET is_default = 1, enabled = 1 WHERE id = @id", new { id }, tx, cancellationToken: ct)); + await tx.CommitAsync(ct); + InvalidateCache(); + return true; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } + + private void InvalidateCache() + { + lock (_lock) { _cachedDefault = null; _cachedAtUtc = DateTime.MinValue; } + } + + private sealed class Row + { + public int Id { get; init; } + public string Name { get; init; } = ""; + public string Provider { get; init; } = ""; + public bool IsDefault { get; init; } + public bool Enabled { get; init; } + public int MaxConcurrency { get; init; } + public long MaxMessageBytes { get; init; } + + public RelayRecord ToRecord() => new() + { + Id = Id, + Name = Name, + Provider = Enum.TryParse(Provider, ignoreCase: true, out var p) ? p : RelayProviderType.Unconfigured, + IsDefault = IsDefault, + Enabled = Enabled, + MaxConcurrency = MaxConcurrency, + MaxMessageBytes = MaxMessageBytes, + }; + } +} diff --git a/src/Dispatch.Data/Repositories/SqlRelaySettingsStore.cs b/src/Dispatch.Data/Repositories/SqlRelaySettingsStore.cs new file mode 100644 index 00000000..e14c5da8 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlRelaySettingsStore.cs @@ -0,0 +1,50 @@ +using System.Collections.Concurrent; +using Dispatch.Core.Configuration; +using Dispatch.Core.Providers; +using Dispatch.Core.Relays; + +namespace Dispatch.Data.Repositories; + +/// +/// Stores a relay's provider + credentials in the SQL config table under relay:{id}:… keys, +/// encrypting secret fields transparently (spec §12.3, §19.5). Reads are cached for a short TTL so the +/// dispatch hot path doesn't query config per message. +/// +public sealed class SqlRelaySettingsStore(IConfigRepository config) : IRelaySettingsStore +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(5); + private readonly ConcurrentDictionary _cache = new(); + + public async Task GetAsync(int relayId, CancellationToken ct = default) + { + if (_cache.TryGetValue(relayId, out var cached) && DateTime.UtcNow - cached.At < CacheTtl) + return cached.Value; + + var providerText = await config.GetAsync($"relay:{relayId}:provider", ct); + // Unset/unparseable → Unconfigured, so a never-configured relay refuses to relay (not silent discard). + var provider = Enum.TryParse(providerText, ignoreCase: true, out var p) + ? p : RelayProviderType.Unconfigured; + + var settings = new Dictionary(); + foreach (var field in RelayProviderSchema.For(provider)) + settings[field.Name] = await config.GetAsync($"relay:{relayId}:{field.ConfigSuffix}", ct); + + var result = new RelaySettings(provider, settings); + _cache[relayId] = (result, DateTime.UtcNow); + return result; + } + + public async Task SaveAsync(int relayId, RelaySettings settings, CancellationToken ct = default) + { + await config.SetAsync($"relay:{relayId}:provider", settings.Provider.ToString(), encrypted: false, ct); + + foreach (var field in RelayProviderSchema.For(settings.Provider)) + { + if (!settings.Settings.TryGetValue(field.Name, out var value) || value is null) + continue; // null = leave existing value untouched (e.g. an unchanged secret) + await config.SetAsync($"relay:{relayId}:{field.ConfigSuffix}", value, field.Secret, ct); + } + + _cache.TryRemove(relayId, out _); + } +} diff --git a/src/Dispatch.Data/Repositories/SqlRetrySettings.cs b/src/Dispatch.Data/Repositories/SqlRetrySettings.cs new file mode 100644 index 00000000..2b96f797 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlRetrySettings.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using Dispatch.Core.Configuration; +using Microsoft.Extensions.Options; + +namespace Dispatch.Data.Repositories; + +/// +/// Retry policy from the SQL config table (spec §12.3 spool.max_retries / spool.retry_delays_seconds), +/// cached briefly so the worker doesn't query per message. Falls back to the appsettings +/// defaults when a key is unset (spec §12.5 fallback). +/// +public sealed class SqlRetrySettings(IConfigRepository config, IOptions defaults) : IRetrySettings +{ + public const string MaxRetriesKey = "spool.max_retries"; + public const string RetryDelaysSecondsKey = "spool.retry_delays_seconds"; + + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(10); + private readonly RetryOptions _defaults = defaults.Value; + private readonly Lock _lock = new(); + private RetryPolicy? _cache; + private DateTime _cachedAtUtc = DateTime.MinValue; + + public async ValueTask GetAsync(CancellationToken ct = default) + { + lock (_lock) + { + if (_cache is not null && DateTime.UtcNow - _cachedAtUtc < CacheTtl) return _cache; + } + + var maxRetries = await ReadIntAsync(MaxRetriesKey, _defaults.MaxRetries, ct); + var delays = await ReadDelaysAsync(ct); + var snapshot = new RetryPolicy(maxRetries, delays); + + lock (_lock) { _cache = snapshot; _cachedAtUtc = DateTime.UtcNow; } + return snapshot; + } + + private async ValueTask ReadIntAsync(string key, int fallback, CancellationToken ct) + { + var raw = await config.GetAsync(key, ct); + return int.TryParse(raw, out var v) ? v : fallback; + } + + private async ValueTask ReadDelaysAsync(CancellationToken ct) + { + var raw = await config.GetAsync(RetryDelaysSecondsKey, ct); + if (raw is not null) + { + try + { + var parsed = JsonSerializer.Deserialize(raw); + if (parsed is { Length: > 0 }) return parsed; + } + catch (JsonException) { /* malformed override - fall back to defaults */ } + } + return _defaults.EffectiveDelaysSeconds; + } +} diff --git a/src/Dispatch.Data/Repositories/SqlRoutingRuleRepository.cs b/src/Dispatch.Data/Repositories/SqlRoutingRuleRepository.cs new file mode 100644 index 00000000..877a96b7 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlRoutingRuleRepository.cs @@ -0,0 +1,126 @@ +using Dapper; +using Dispatch.Core.Routing; + +namespace Dispatch.Data.Repositories; + +/// Reads/writes the ordered routing_rules table (spec §10.3, §10.5, §10.10). +public sealed class SqlRoutingRuleRepository(SqlConnectionFactory factory) : IRoutingRuleRepository +{ + private const string SelectColumns = + "id, priority, name, recipient_pattern AS RecipientPattern, sender_pattern AS SenderPattern, relay_id AS RelayId, enabled"; + private const string InsertedColumns = + "INSERTED.id, INSERTED.priority, INSERTED.name, INSERTED.recipient_pattern AS RecipientPattern, INSERTED.sender_pattern AS SenderPattern, INSERTED.relay_id AS RelayId, INSERTED.enabled"; + + public async Task> GetEnabledOrderedAsync(CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync(new CommandDefinition( + $"SELECT {SelectColumns} FROM routing_rules WHERE enabled = 1", cancellationToken: ct)); + // Specificity is computed in code (spec §10.5: priority ASC, then specificity DESC). + return rows.Select(r => r.ToRule()) + .OrderBy(r => r.Priority) + .ThenByDescending(r => r.Specificity) + .ToList(); + } + + public async Task> GetAllAsync(CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync(new CommandDefinition( + $"SELECT {SelectColumns} FROM routing_rules ORDER BY priority", cancellationToken: ct)); + return rows.Select(r => r.ToRule()).ToList(); + } + + public async Task CreateAsync(RoutingRule rule, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var priority = rule.Priority > 0 + ? rule.Priority + : (await cn.ExecuteScalarAsync(new CommandDefinition( + "SELECT MAX(priority) FROM routing_rules", cancellationToken: ct)) ?? 0) + 10; + + const string sql = $""" + INSERT INTO routing_rules (priority, name, recipient_pattern, sender_pattern, relay_id, enabled) + OUTPUT {InsertedColumns} + VALUES (@priority, @Name, @RecipientPattern, @SenderPattern, @RelayId, @Enabled); + """; + var row = await cn.QuerySingleAsync(new CommandDefinition(sql, new + { + priority, rule.Name, rule.RecipientPattern, rule.SenderPattern, rule.RelayId, rule.Enabled, + }, cancellationToken: ct)); + return row.ToRule(); + } + + public async Task UpdateAsync(RoutingRule rule, CancellationToken ct = default) + { + const string sql = """ + UPDATE routing_rules SET name = @Name, recipient_pattern = @RecipientPattern, + sender_pattern = @SenderPattern, relay_id = @RelayId, enabled = @Enabled + WHERE id = @Id; + """; + await using var cn = await factory.OpenAsync(ct); + var n = await cn.ExecuteAsync(new CommandDefinition(sql, new + { + rule.Id, rule.Name, rule.RecipientPattern, rule.SenderPattern, rule.RelayId, rule.Enabled, + }, cancellationToken: ct)); + return n > 0; + } + + public async Task DeleteAsync(int id, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var n = await cn.ExecuteAsync(new CommandDefinition( + "DELETE FROM routing_rules WHERE id = @id", new { id }, cancellationToken: ct)); + return n > 0; + } + + public async Task ReorderAsync(IReadOnlyList idsInPriorityOrder, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + await using var tx = await cn.BeginTransactionAsync(ct); + try + { + // Two-phase to dodge the UNIQUE(priority) constraint: park at negatives, then set final values. + for (var i = 0; i < idsInPriorityOrder.Count; i++) + await cn.ExecuteAsync(new CommandDefinition( + "UPDATE routing_rules SET priority = @p WHERE id = @id", + new { p = -(i + 1), id = idsInPriorityOrder[i] }, tx, cancellationToken: ct)); + + for (var i = 0; i < idsInPriorityOrder.Count; i++) + await cn.ExecuteAsync(new CommandDefinition( + "UPDATE routing_rules SET priority = @p WHERE id = @id", + new { p = (i + 1) * 10, id = idsInPriorityOrder[i] }, tx, cancellationToken: ct)); + + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + } + + public async Task CountReferencingRelayAsync(int relayId, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + return await cn.ExecuteScalarAsync(new CommandDefinition( + "SELECT COUNT(*) FROM routing_rules WHERE relay_id = @relayId", new { relayId }, cancellationToken: ct)); + } + + private sealed class Row + { + public int Id { get; init; } + public int Priority { get; init; } + public string Name { get; init; } = ""; + public string? RecipientPattern { get; init; } + public string? SenderPattern { get; init; } + public int RelayId { get; init; } + public bool Enabled { get; init; } + + public RoutingRule ToRule() => new() + { + Id = Id, Priority = Priority, Name = Name, RecipientPattern = RecipientPattern, + SenderPattern = SenderPattern, RelayId = RelayId, Enabled = Enabled, + }; + } +} diff --git a/src/Dispatch.Data/Repositories/SqlSmtpCredentialRepository.cs b/src/Dispatch.Data/Repositories/SqlSmtpCredentialRepository.cs new file mode 100644 index 00000000..42ea798c --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlSmtpCredentialRepository.cs @@ -0,0 +1,60 @@ +using Dapper; +using Dispatch.Core.Smtp; + +namespace Dispatch.Data.Repositories; + +/// SMTP AUTH allow-list backed by config_smtp_credentials; passwords bcrypt-hashed (cost 12). +public sealed class SqlSmtpCredentialRepository(SqlConnectionFactory factory) : ISmtpCredentialRepository +{ + private const int WorkFactor = 12; + private static readonly string DummyHash = BCrypt.Net.BCrypt.HashPassword("dummy", WorkFactor); + + public async Task VerifyAsync(string username, string password, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var hash = await cn.QuerySingleOrDefaultAsync(new CommandDefinition( + "SELECT password_hash FROM config_smtp_credentials WHERE username = @username", new { username }, cancellationToken: ct)); + + if (hash is null) + { + BCrypt.Net.BCrypt.Verify(password, DummyHash); // constant-time on unknown user + return false; + } + if (!BCrypt.Net.BCrypt.Verify(password, hash)) return false; + + await cn.ExecuteAsync(new CommandDefinition( + "UPDATE config_smtp_credentials SET last_used_at = SYSUTCDATETIME() WHERE username = @username", + new { username }, cancellationToken: ct)); + return true; + } + + public async Task> ListAsync(CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var rows = await cn.QueryAsync(new CommandDefinition( + "SELECT id AS Id, username AS Username, created_at AS CreatedAt, last_used_at AS LastUsedAt FROM config_smtp_credentials ORDER BY username", + cancellationToken: ct)); + return rows.ToList(); + } + + public async Task AddAsync(string username, string password, CancellationToken ct = default) + { + var hash = BCrypt.Net.BCrypt.HashPassword(password, WorkFactor); + const string sql = """ + MERGE config_smtp_credentials AS t + USING (VALUES (@username)) AS s(username) ON t.username = s.username + WHEN MATCHED THEN UPDATE SET password_hash = @hash + WHEN NOT MATCHED THEN INSERT (username, password_hash) VALUES (@username, @hash); + """; + await using var cn = await factory.OpenAsync(ct); + await cn.ExecuteAsync(new CommandDefinition(sql, new { username, hash }, cancellationToken: ct)); + } + + public async Task DeleteAsync(string username, CancellationToken ct = default) + { + await using var cn = await factory.OpenAsync(ct); + var n = await cn.ExecuteAsync(new CommandDefinition( + "DELETE FROM config_smtp_credentials WHERE username = @username", new { username }, cancellationToken: ct)); + return n > 0; + } +} diff --git a/src/Dispatch.Data/Repositories/SqlStorageReport.cs b/src/Dispatch.Data/Repositories/SqlStorageReport.cs new file mode 100644 index 00000000..04dcdd92 --- /dev/null +++ b/src/Dispatch.Data/Repositories/SqlStorageReport.cs @@ -0,0 +1,59 @@ +using Dapper; +using Dispatch.Core.Maintenance; + +namespace Dispatch.Data.Repositories; + +/// +/// Computes database-side storage usage (spec §6.10): per-event relay_log row counts, the relay_log and +/// audit_log table sizes, and the audit row counts. Table sizes come from sys.allocation_units (the same +/// figures sp_spaceused reports). Best-effort: if SQL is unreachable, returns a not-connected snapshot +/// rather than throwing, so the dashboard storage view degrades gracefully. +/// +public sealed class SqlStorageReport(SqlConnectionFactory factory) : IStorageReport +{ + public async Task GetAsync(CancellationToken ct = default) + { + try + { + await using var cn = await factory.OpenAsync(ct); + + var dbBytes = await cn.ExecuteScalarAsync(new CommandDefinition( + "SELECT CAST(SUM(size) AS BIGINT) * 8 * 1024 FROM sys.database_files WHERE type = 0;", + cancellationToken: ct)); + + var byEvent = (await cn.QueryAsync(new CommandDefinition( + "SELECT event AS [Event], COUNT_BIG(*) AS Rows FROM relay_log GROUP BY event;", + cancellationToken: ct))).ToList(); + + var relayLogBytes = await TableBytesAsync(cn, "relay_log", ct); + var auditBytes = await TableBytesAsync(cn, "audit_log", ct); + + var audit = await cn.QuerySingleAsync<(long Total, long Security)>(new CommandDefinition( + """ + SELECT COUNT_BIG(*) AS Total, + ISNULL(SUM(CASE WHEN category IN ('Access','SmtpAuth') THEN 1 ELSE 0 END), 0) AS Security + FROM audit_log; + """, + cancellationToken: ct)); + + return new DbStorage(true, dbBytes, relayLogBytes, byEvent, auditBytes, audit.Total, audit.Security); + } + catch + { + return new DbStorage(false, 0, 0, [], 0, 0, 0); + } + } + + // Total allocated bytes for a table (data + indexes), from the allocation-unit page counts (8 KB pages). + private static async Task TableBytesAsync(System.Data.Common.DbConnection cn, string table, CancellationToken ct) => + await cn.ExecuteScalarAsync(new CommandDefinition( + """ + SELECT ISNULL(SUM(a.total_pages), 0) * 8 * 1024 + FROM sys.tables t + JOIN sys.indexes i ON t.object_id = i.object_id + JOIN sys.partitions p ON i.object_id = p.object_id AND i.index_id = p.index_id + JOIN sys.allocation_units a ON p.partition_id = a.container_id + WHERE t.name = @table; + """, + new { table }, cancellationToken: ct)); +} diff --git a/src/Dispatch.Data/SecureConfig.cs b/src/Dispatch.Data/SecureConfig.cs new file mode 100644 index 00000000..69d1975a --- /dev/null +++ b/src/Dispatch.Data/SecureConfig.cs @@ -0,0 +1,177 @@ +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; + +namespace Dispatch.Data; + +/// +/// Transparent encryption for config rows flagged encrypted (spec §17.5, §19.5). On every platform it +/// uses AES-256-GCM with a random 256-bit key persisted as .dispatch-key in the directory set via +/// (mode 600 on Unix; an inheritance-stripped ACL granting only SYSTEM, +/// Administrators, and the service account on Windows). Because the key lives in a portable file rather than a +/// machine-bound store, a database backup can be restored on a different host by also restoring the key file - +/// so disaster recovery and migration work the same on Windows, Linux, and macOS. An exfiltrated config +/// table still can't be decrypted without that host-local key file. If no key directory is set (or it's +/// unwritable) it falls back to a machine-derived key (weaker; surfaced via ). +/// AES blob: base64( nonce[12] | tag[16] | ct ). +/// +/// Backward compatibility: earlier Windows builds encrypted with DPAPI (LocalMachine). +/// reads those legacy blobs on Windows when AES decryption fails, and they migrate to AES on the next save. +/// +public static class SecureConfig +{ + private const int NonceSize = 12; + private const int TagSize = 16; + private const string KeyFileName = ".dispatch-key"; + private static readonly byte[] AppSalt = Encoding.UTF8.GetBytes("Dispatch.SMTP.Relay.v1.config-salt"); + private static string? _keyDir; + private static readonly Lazy Key = new(DeriveKey); + + /// Sets the durable directory holding the random AES key (non-Windows). Call before the first + /// Encrypt/Decrypt and point it at a persistent location (the spool/data dir). + public static void UseKeyDirectory(string dir) => _keyDir = dir; + + /// True once at-rest encryption has fallen back to the (non-secret) machine-derived key because no + /// writable key directory was available - a weaker posture the host should surface to the operator. + public static bool UsedMachineKeyFallback { get; private set; } + + public static string Encrypt(string plaintext) => EncryptWith(Key.Value, plaintext); + + public static string Decrypt(string ciphertext) + { + // Current format (all platforms): AES-256-GCM with the portable key file. + try + { + return DecryptWith(Key.Value, ciphertext); + } + catch (Exception ex) when (OperatingSystem.IsWindows() + && ex is CryptographicException or FormatException or ArgumentException) + { + // Not an AES blob we can open - likely a legacy DPAPI value from an older Windows build. + // Re-encrypts to AES on the next save. + return Encoding.UTF8.GetString( + ProtectedData.Unprotect(Convert.FromBase64String(ciphertext), AppSalt, DataProtectionScope.LocalMachine)); + } + } + + /// AES-256-GCM encrypt with an explicit key. Blob = base64( nonce[12] | tag[16] | ct ). The key + /// is portable: anything produced can be decrypted on any host with the same key + /// (the backup/restore-to-another-machine guarantee). Used internally and by tests. + internal static string EncryptWith(byte[] key, string plaintext) + { + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var pt = Encoding.UTF8.GetBytes(plaintext); + var ct = new byte[pt.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(key, TagSize); + aes.Encrypt(nonce, pt, ct, tag); + + var blob = new byte[NonceSize + TagSize + ct.Length]; + Buffer.BlockCopy(nonce, 0, blob, 0, NonceSize); + Buffer.BlockCopy(tag, 0, blob, NonceSize, TagSize); + Buffer.BlockCopy(ct, 0, blob, NonceSize + TagSize, ct.Length); + return Convert.ToBase64String(blob); + } + + /// AES-256-GCM decrypt with an explicit key. Throws if the + /// key is wrong or the blob is tampered/too short (GCM authentication) - never returns wrong plaintext. + internal static string DecryptWith(byte[] key, string ciphertext) + { + var blob = Convert.FromBase64String(ciphertext); + if (blob.Length < NonceSize + TagSize) + throw new CryptographicException("Ciphertext too short to be an AES-GCM blob."); + + var nonce = blob.AsSpan(0, NonceSize); + var tag = blob.AsSpan(NonceSize, TagSize); + var ct = blob.AsSpan(NonceSize + TagSize); + var pt = new byte[ct.Length]; + using var aes = new AesGcm(key, TagSize); + aes.Decrypt(nonce, ct, tag, pt); + return Encoding.UTF8.GetString(pt); + } + + private static byte[] DeriveKey() + { + // Preferred: a random 256-bit key persisted in the configured durable directory (mode 600). + if (_keyDir is { } dir && TryLoadOrCreateKeyFile(dir) is { } fileKey) + return fileKey; + + // Fallback only when no writable key directory is available: a machine-derived key. Weaker (the + // machine id is not secret) but keeps the app functional; UseKeyDirectory should be set in normal runs. + UsedMachineKeyFallback = true; + Console.Error.WriteLine( + "SECURITY WARNING: at-rest encryption is using a machine-derived key because no writable key " + + "directory was available. Stored secrets (provider credentials, TLS password) are weakly protected - " + + "an attacker with the config DB and the host machine-id could decrypt them. Set DISPATCH_KEY_DIR to a " + + "persistent, writable, private directory so a random key file is used instead."); + var machine = TryReadMachineId() ?? Environment.MachineName; + return Rfc2898DeriveBytes.Pbkdf2( + Encoding.UTF8.GetBytes(machine), AppSalt, 100_000, HashAlgorithmName.SHA256, 32); + } + + private static byte[]? TryLoadOrCreateKeyFile(string dir) + { + try + { + var path = Path.Combine(dir, KeyFileName); + if (File.Exists(path)) + { + var existing = Convert.FromBase64String(File.ReadAllText(path).Trim()); + if (existing.Length == 32) return existing; + } + Directory.CreateDirectory(dir); + var key = RandomNumberGenerator.GetBytes(32); + // Create restricted, then write - keep the private key off other users. ProgramData is readable by + // all users by default on Windows, so an explicit ACL matters there as much as mode 600 on Unix. + using (File.Create(path)) { } + if (OperatingSystem.IsWindows()) + { + // Best-effort: an un-ACL'd key file still works (and Program Files is admin-write-only), so a + // failure here must not abort key creation and trigger the weaker machine-key fallback. + try { RestrictWindowsAcl(path); } catch { /* keep going with default ACLs */ } + } + else + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); // 600 + } + File.WriteAllText(path, Convert.ToBase64String(key)); + return key; + } + catch + { + return null; // fall back to the machine-derived key + } + } + + /// Lock the key file down to SYSTEM, Administrators, and the running service account, with + /// inheritance disabled - so other local users on a Windows box can't read it out of ProgramData. + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static void RestrictWindowsAcl(string path) + { + var fi = new FileInfo(path); + var sec = new FileSecurity(); + sec.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); // drop inherited ACEs + void Grant(IdentityReference id) => + sec.AddAccessRule(new FileSystemAccessRule(id, FileSystemRights.FullControl, AccessControlType.Allow)); + Grant(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null)); + Grant(new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null)); + if (WindowsIdentity.GetCurrent().User is { } me) Grant(me); // the service account, so it keeps access + fi.SetAccessControl(sec); + } + + private static string? TryReadMachineId() + { + try + { + return File.Exists("/etc/machine-id") + ? File.ReadAllText("/etc/machine-id").Trim() + : null; + } + catch + { + return null; + } + } +} diff --git a/src/Dispatch.Data/ServiceCollectionExtensions.cs b/src/Dispatch.Data/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..a37c3f08 --- /dev/null +++ b/src/Dispatch.Data/ServiceCollectionExtensions.cs @@ -0,0 +1,46 @@ +using Dispatch.Core.ApiKeys; +using Dispatch.Core.Configuration; +using Dispatch.Core.Counters; +using Dispatch.Core.Logging; +using Dispatch.Core.Relays; +using Dispatch.Core.Routing; +using Dispatch.Core.Smtp; +using Dispatch.Data.Repositories; +using Microsoft.Extensions.DependencyInjection; + +namespace Dispatch.Data; + +public static class ServiceCollectionExtensions +{ + /// Registers the SQL connection factory, migration runner, and all SQL repositories. + public static IServiceCollection AddDispatchData(this IServiceCollection services, string connectionString) + { + services.AddSingleton(new SqlConnectionFactory(connectionString)); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/Dispatch.Data/SqlConnectionFactory.cs b/src/Dispatch.Data/SqlConnectionFactory.cs new file mode 100644 index 00000000..48592999 --- /dev/null +++ b/src/Dispatch.Data/SqlConnectionFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.Data.SqlClient; + +namespace Dispatch.Data; + +/// Creates SQL connections from the configured connection string. +public sealed class SqlConnectionFactory(string connectionString) +{ + public string ConnectionString { get; } = connectionString; + + public SqlConnection Create() => new(ConnectionString); + + public async Task OpenAsync(CancellationToken ct = default) + { + var cn = new SqlConnection(ConnectionString); + await cn.OpenAsync(ct); + return cn; + } +} diff --git a/src/Dispatch.Providers/AmazonSesProvider.cs b/src/Dispatch.Providers/AmazonSesProvider.cs new file mode 100644 index 00000000..9cbe0b11 --- /dev/null +++ b/src/Dispatch.Providers/AmazonSesProvider.cs @@ -0,0 +1,47 @@ +using Amazon; +using Amazon.Runtime; +using Amazon.SimpleEmailV2; +using Amazon.SimpleEmailV2.Model; +using Dispatch.Core.Providers; + +namespace Dispatch.Providers; + +/// +/// Amazon SES upstream relay via the SES v2 SendEmail API with raw MIME content (spec §8), so attachments + +/// headers are preserved exactly. Settings: AccessKeyId, SecretAccessKey, Region (e.g. us-east-1). Throttling +/// / 5xx map to . +/// +public sealed class AmazonSesProvider(RelayConfig config) : IRelayProvider +{ + public string Name => "AmazonSes"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var accessKey = ProviderHttp.Require(config, Name, "AccessKeyId"); + var secretKey = ProviderHttp.Require(config, Name, "SecretAccessKey"); + var region = ProviderHttp.Require(config, Name, "Region"); + + using var raw = new MemoryStream(); + await message.Message.WriteToAsync(raw, ct); + raw.Position = 0; + + var request = new SendEmailRequest + { + Destination = new Destination { ToAddresses = ProviderHttp.Recipients(message).ToList() }, + Content = new EmailContent { Raw = new RawMessage { Data = raw } }, + }; + + using var client = new AmazonSimpleEmailServiceV2Client( + new BasicAWSCredentials(accessKey, secretKey), RegionEndpoint.GetBySystemName(region)); + + try + { + var resp = await client.SendEmailAsync(request, ct); + return RelayResult.Success(resp.MessageId, $"SES MessageId: {resp.MessageId}"); + } + catch (AmazonServiceException ex) when ((int)ex.StatusCode == 429 || (int)ex.StatusCode >= 500) + { + throw new TransientRelayException($"Amazon SES transient error: {ex.Message}", ex); + } + } +} diff --git a/src/Dispatch.Providers/AzureProvider.cs b/src/Dispatch.Providers/AzureProvider.cs new file mode 100644 index 00000000..a473ecf5 --- /dev/null +++ b/src/Dispatch.Providers/AzureProvider.cs @@ -0,0 +1,109 @@ +using Azure; +using Azure.Communication.Email; +using Dispatch.Core.Providers; +using MimeKit; + +namespace Dispatch.Providers; + +/// +/// Azure Communication Services email relay via the SDK (spec §8.3). +/// Settings: ConnectionString, MailFrom (one or more comma-separated MailFrom addresses). ACS only accepts +/// mail from a MailFrom address explicitly configured on the resource (there is no domain-level wildcard), +/// so the message's From must exactly match one of the configured addresses or it's rejected locally (no +/// ACS call); a message with no From is sent as the first configured MailFrom. Azure 429/5xx +/// (RequestFailedException) are transient. +/// +public sealed class AzureProvider(RelayConfig config, IEmailClientFactory clientFactory) : IRelayProvider +{ + public string Name => "AzureCommunication"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var connectionString = config.Settings.TryGetValue("ConnectionString", out var cs) ? cs : null; + if (string.IsNullOrWhiteSpace(connectionString)) + throw new InvalidOperationException("Azure relay 'ConnectionString' is not configured."); + + var mailFroms = ParseMailFroms(config.Settings.TryGetValue("MailFrom", out var mf) ? mf : null); + if (mailFroms.Count == 0) + throw new InvalidOperationException("Azure relay 'MailFrom' is not configured."); + + var mime = message.Message; + + // Closed policy: the message's From must be one of the configured MailFrom addresses (exact match - + // ACS has no domain wildcard) or it's rejected before touching ACS. A message with no From is sent + // as the first configured MailFrom. The matching/chosen MailFrom is the ACS sender. + var from = !string.IsNullOrWhiteSpace(message.FromAddress) + ? message.FromAddress + : mime.From.Mailboxes.FirstOrDefault()?.Address; + + string sender; + if (string.IsNullOrWhiteSpace(from)) + sender = mailFroms[0]; + else if (IsAllowedSender(from, mailFroms)) + sender = from; + else + throw new InvalidOperationException( + $"Sender '{from}' is not a configured MailFrom for this Azure relay - message rejected and not sent to ACS. Configured: {string.Join(", ", mailFroms)}."); + + var rcpt = ProviderHttp.SplitRecipients(message); + + var content = new EmailContent(mime.Subject ?? "") + { + PlainText = mime.TextBody, + Html = mime.HtmlBody, + }; + var emailRecipients = new EmailRecipients( + rcpt.To.Select(r => new EmailAddress(r)).ToList(), + rcpt.Cc.Select(r => new EmailAddress(r)).ToList(), + rcpt.Bcc.Select(r => new EmailAddress(r)).ToList()); + var emailMessage = new EmailMessage(sender, emailRecipients, content); + + foreach (var att in mime.Attachments) + { + if (att is not MimePart part || part.Content is null) continue; + using var ms = new MemoryStream(); + part.Content.DecodeTo(ms); + emailMessage.Attachments.Add(new EmailAttachment( + part.FileName ?? "attachment", + part.ContentType?.MimeType ?? "application/octet-stream", + BinaryData.FromBytes(ms.ToArray()))); + } + + var client = clientFactory.Create(connectionString); + try + { + var operation = await client.SendAsync(WaitUntil.Completed, emailMessage, ct); + // Spec §11.6 detail format. + return RelayResult.Success(operation.Id, $"OperationId: {operation.Id}, Status: {operation.Value.Status}"); + } + catch (RequestFailedException ex) when (ex.Status is 429 or >= 500) + { + throw new TransientRelayException($"Azure {ex.Status}: {ex.Message}", ex); + } + } + + /// Parses a comma/semicolon/newline-separated MailFrom list into trimmed addresses. + private static List ParseMailFroms(string? raw) => + string.IsNullOrWhiteSpace(raw) + ? [] + : raw.Split([',', ';', '\n', '\r'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + + /// + /// True when exactly matches one of the configured MailFrom addresses + /// (case-insensitive). ACS has no domain-level wildcard - each sender must be listed explicitly. + /// + private static bool IsAllowedSender(string from, IEnumerable allowed) => + allowed.Any(e => string.Equals(e, from, StringComparison.OrdinalIgnoreCase)); +} + +/// Indirection so the Azure EmailClient can be faked in tests. +public interface IEmailClientFactory +{ + EmailClient Create(string connectionString); +} + +public sealed class EmailClientFactory : IEmailClientFactory +{ + public EmailClient Create(string connectionString) => new(connectionString); +} diff --git a/src/Dispatch.Providers/BirdProvider.cs b/src/Dispatch.Providers/BirdProvider.cs new file mode 100644 index 00000000..13b7a506 --- /dev/null +++ b/src/Dispatch.Providers/BirdProvider.cs @@ -0,0 +1,57 @@ +using System.Net.Http; +using System.Text.Json; +using Dispatch.Core.Providers; + +namespace Dispatch.Providers; + +/// +/// Bird (formerly SparkPost/MessageBird) upstream relay via the Channels API (spec §8). Structured JSON send +/// to a workspace's email channel - the channel defines the sender. Settings: AccessKey, WorkspaceId, +/// ChannelId. Auth via Authorization: AccessKey <token>. Returns 202 with { id }. +/// Note: attachments are not forwarded (Bird takes hosted media URLs, not inline bytes). +/// +public sealed class BirdProvider(RelayConfig config, HttpClient http) : IRelayProvider +{ + public string Name => "Bird"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + // Bird's dashboard calls this an "API key"; the wire header still uses the AccessKey scheme. + var apiKey = ProviderHttp.Require(config, Name, "ApiKey"); + var workspaceId = ProviderHttp.Require(config, Name, "WorkspaceId"); + var channelId = ProviderHttp.Require(config, Name, "ChannelId"); + var html = ProviderHttp.Html(message); + var text = ProviderHttp.Text(message); + + var contacts = ProviderHttp.Recipients(message) + .Select(r => new Dictionary { ["identifierKey"] = "emailaddress", ["identifierValue"] = r }) + .ToArray(); + + var body = new Dictionary + { + ["receiver"] = new Dictionary { ["contacts"] = contacts }, + ["body"] = new Dictionary + { + ["type"] = "html", + ["html"] = new Dictionary { ["html"] = html ?? text ?? "", ["text"] = text ?? "" }, + ["metadata"] = new Dictionary + { + ["html"] = new Dictionary { ["subject"] = ProviderHttp.Subject(message) }, + }, + }, + }; + + var url = $"https://api.bird.com/workspaces/{Uri.EscapeDataString(workspaceId)}/channels/{Uri.EscapeDataString(channelId)}/messages"; + using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = ProviderHttp.Json(body) }; + req.Headers.Add("Authorization", $"AccessKey {apiKey}"); + + var (status, json) = await ProviderHttp.SendAsync(http, req, Name, ct); + + // Success is 202 with { "id": "" }. + string? id = null; + try { if (JsonDocument.Parse(json).RootElement.TryGetProperty("id", out var m)) id = m.GetString(); } + catch { /* best-effort */ } + + return RelayResult.Success(id, $"HTTP {status} - Bird id: {id}"); + } +} diff --git a/src/Dispatch.Providers/Dispatch.Providers.csproj b/src/Dispatch.Providers/Dispatch.Providers.csproj new file mode 100644 index 00000000..1c4e6c8f --- /dev/null +++ b/src/Dispatch.Providers/Dispatch.Providers.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Dispatch.Providers/LocalProvider.cs b/src/Dispatch.Providers/LocalProvider.cs new file mode 100644 index 00000000..4a829e26 --- /dev/null +++ b/src/Dispatch.Providers/LocalProvider.cs @@ -0,0 +1,28 @@ +using Dispatch.Core.Providers; + +namespace Dispatch.Providers; + +/// +/// Local / developer-mode provider (spec §8.5). Never opens a network connection or delivers to the +/// outside world. When a capture directory is configured, each message is written there as an +/// .eml file so developers can inspect it (and view it in the Local Inbox); otherwise the +/// message is accepted and discarded. Either way it is logged as delivered. +/// +public sealed class LocalProvider(string? captureDirectory = null) : IRelayProvider +{ + public string Name => "Local"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + if (string.IsNullOrEmpty(captureDirectory)) + return RelayResult.Success(id: $"local-{Guid.NewGuid():N}", detail: "Discarded (local/dev mode - no external delivery)"); + + Directory.CreateDirectory(captureDirectory); + var name = (message.SpoolId is { Length: > 0 } id ? id : Guid.NewGuid().ToString("N")) + ".eml"; + var path = Path.Combine(captureDirectory, name); + await using (var fs = File.Create(path)) + await message.Message.WriteToAsync(fs, ct); + + return RelayResult.Success(id: name, detail: $"Captured locally to {path} (no external delivery)"); + } +} diff --git a/src/Dispatch.Providers/MailerooProvider.cs b/src/Dispatch.Providers/MailerooProvider.cs new file mode 100644 index 00000000..7a815ad5 --- /dev/null +++ b/src/Dispatch.Providers/MailerooProvider.cs @@ -0,0 +1,87 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using Dispatch.Core.Providers; +using MimeKit; + +namespace Dispatch.Providers; + +/// +/// Maileroo upstream relay via the Email API v2 (spec §8): structured JSON send with Bearer auth. +/// from/to/cc are {address, display_name} objects; bodies are html/plain; attachments are base64. +/// Settings: ApiKey (sending key). Returns { data.reference_id }. +/// +public sealed class MailerooProvider(RelayConfig config, HttpClient http) : IRelayProvider +{ + public string Name => "Maileroo"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var apiKey = ProviderHttp.Require(config, Name, "ApiKey"); + var html = ProviderHttp.Html(message); + var text = ProviderHttp.Text(message); + + var rcpt = ProviderHttp.SplitRecipients(message); + var fromMb = message.Message.From.Mailboxes.FirstOrDefault(); + var body = new Dictionary + { + ["from"] = Address(fromMb?.Address ?? message.FromAddress, fromMb?.Name), + ["to"] = AddressList(rcpt.To), + ["subject"] = ProviderHttp.Subject(message), + }; + if (rcpt.Cc.Count > 0) body["cc"] = AddressList(rcpt.Cc); + if (rcpt.Bcc.Count > 0) body["bcc"] = AddressList(rcpt.Bcc); + if (html is not null) body["html"] = html; + if (text is not null) body["plain"] = text; + if (html is null && text is null) body["plain"] = ""; + + var attachments = Attachments(message); + if (attachments.Count > 0) body["attachments"] = attachments; + + using var req = new HttpRequestMessage(HttpMethod.Post, "https://smtp.maileroo.com/api/v2/emails") + { + Content = ProviderHttp.Json(body), + }; + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + var (status, json) = await ProviderHttp.SendAsync(http, req, Name, ct); + + // Success shape: { "success": true, "data": { "reference_id": "..." } } + string? id = null; + try + { + var root = JsonDocument.Parse(json).RootElement; + if (root.TryGetProperty("data", out var d) && d.TryGetProperty("reference_id", out var r)) + id = r.GetString(); + } + catch { /* best-effort */ } + + return RelayResult.Success(id, $"HTTP {status} - Maileroo reference: {id}"); + } + + private static Dictionary Address(string addr, string? name) => + string.IsNullOrWhiteSpace(name) + ? new Dictionary { ["address"] = addr } + : new Dictionary { ["address"] = addr, ["display_name"] = name }; + + private static List AddressList(IReadOnlyList addrs) => + addrs.Select(a => (object)Address(a, null)).ToList(); + + private static List Attachments(RelayMessage m) + { + var list = new List(); + foreach (var att in m.Message.Attachments) + { + if (att is not MimePart part || part.Content is null) continue; + using var ms = new MemoryStream(); + part.Content.DecodeTo(ms); + list.Add(new Dictionary + { + ["file_name"] = part.FileName ?? "attachment", + ["content"] = Convert.ToBase64String(ms.ToArray()), + ["content_type"] = part.ContentType?.MimeType ?? "application/octet-stream", + }); + } + return list; + } +} diff --git a/src/Dispatch.Providers/MailgunProvider.cs b/src/Dispatch.Providers/MailgunProvider.cs new file mode 100644 index 00000000..13a8a47c --- /dev/null +++ b/src/Dispatch.Providers/MailgunProvider.cs @@ -0,0 +1,102 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Dispatch.Core.Providers; + +namespace Dispatch.Providers; + +/// +/// Mailgun upstream relay via the Messages API (spec §8.1). Posts the raw MIME to /messages.mime +/// so attachments and headers are preserved exactly. Settings: ApiKey, Domain, Region (US|EU). +/// 4xx-except-auth and 5xx/429 map to . +/// +public sealed class MailgunProvider(RelayConfig config, HttpClient http) : IRelayProvider +{ + public string Name => "Mailgun"; + + // A DNS hostname: labels of letters/digits/hyphens separated by dots. No slashes, dots-dots, or other + // path/query characters that could redirect the request within the (pinned) Mailgun host. + private static readonly System.Text.RegularExpressions.Regex DomainPattern = + new(@"^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$", + System.Text.RegularExpressions.RegexOptions.Compiled); + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var apiKey = Require("ApiKey"); + var domain = Require("Domain").Trim(); + // The domain is interpolated into the request path; reject anything that isn't a bare hostname so it + // can't alter the URL path (e.g. slashes, "..", query/fragment) - the host itself is already pinned. + if (!DomainPattern.IsMatch(domain)) + throw new InvalidOperationException($"Mailgun relay 'Domain' is not a valid hostname: '{domain}'."); + var region = (Setting("Region") ?? "US").Trim().ToUpperInvariant(); + var baseUrl = region == "EU" ? "https://api.eu.mailgun.net" : "https://api.mailgun.net"; + + byte[] mime; + using (var ms = new MemoryStream()) + { + await message.Message.WriteToAsync(ms, ct); + mime = ms.ToArray(); + } + + using var content = new MultipartFormDataContent(); + foreach (var recipient in message.ToAddresses) + content.Add(new StringContent(recipient), "to"); + + var mimePart = new ByteArrayContent(mime); + mimePart.Headers.ContentType = new MediaTypeHeaderValue("message/rfc822"); + content.Add(mimePart, "message", "message.mime"); + + using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v3/{domain}/messages.mime") + { + Content = content, + }; + request.Headers.Authorization = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"api:{apiKey}"))); + + HttpResponseMessage response; + try + { + response = await http.SendAsync(request, ct); + } + catch (HttpRequestException ex) + { + throw new TransientRelayException($"Mailgun request failed: {ex.Message}", ex); + } + + var bodyText = await response.Content.ReadAsStringAsync(ct); + if (!response.IsSuccessStatusCode) + { + var detail = $"Mailgun {(int)response.StatusCode}: {bodyText}"; + if (IsTransient(response.StatusCode)) + throw new TransientRelayException(detail); + throw new InvalidOperationException(detail); + } + + string? id = null; + string? statusMessage = null; + try + { + var root = JsonDocument.Parse(bodyText).RootElement; + id = root.GetProperty("id").GetString(); + if (root.TryGetProperty("message", out var m)) statusMessage = m.GetString(); + } + catch { /* id/message are best-effort */ } + + // Spec §11.6 detail format. + return RelayResult.Success(id, $"HTTP {(int)response.StatusCode} - id: {id}, message: {statusMessage ?? "Queued"}"); + } + + private static bool IsTransient(HttpStatusCode code) => + (int)code is 429 or >= 500 && (int)code < 600; + + private string? Setting(string key) => config.Settings.TryGetValue(key, out var v) ? v : null; + + private string Require(string key) + { + var v = Setting(key); + if (string.IsNullOrWhiteSpace(v)) + throw new InvalidOperationException($"Mailgun relay '{key}' is not configured."); + return v; + } +} diff --git a/src/Dispatch.Providers/PostmarkProvider.cs b/src/Dispatch.Providers/PostmarkProvider.cs new file mode 100644 index 00000000..97116fbd --- /dev/null +++ b/src/Dispatch.Providers/PostmarkProvider.cs @@ -0,0 +1,65 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using Dispatch.Core.Providers; + +namespace Dispatch.Providers; + +/// +/// Postmark upstream relay via the Email API (spec §8). Sends a structured email (from/to/subject/bodies). +/// Settings: ApiKey (server token), optional MessageStream (default "outbound"). Postmark returns 200 with an +/// ErrorCode - a non-zero ErrorCode is a permanent failure even on HTTP 200. +/// +public sealed class PostmarkProvider(RelayConfig config, HttpClient http) : IRelayProvider +{ + public string Name => "Postmark"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var token = ProviderHttp.Require(config, Name, "ApiKey"); + var html = ProviderHttp.Html(message); + var text = ProviderHttp.Text(message); + + var rcpt = ProviderHttp.SplitRecipients(message); + var body = new Dictionary + { + ["From"] = ProviderHttp.From(message), + ["To"] = string.Join(",", rcpt.To), + ["Subject"] = ProviderHttp.Subject(message), + ["MessageStream"] = ProviderHttp.Setting(config, "MessageStream") ?? "outbound", + }; + if (rcpt.Cc.Count > 0) body["Cc"] = string.Join(",", rcpt.Cc); + if (rcpt.Bcc.Count > 0) body["Bcc"] = string.Join(",", rcpt.Bcc); + if (html is not null) body["HtmlBody"] = html; + if (text is not null) body["TextBody"] = text; + if (html is null && text is null) body["TextBody"] = ""; + + var attachments = ProviderHttp.Attachments(message); + if (attachments.Count > 0) + body["Attachments"] = attachments.Select(a => new Dictionary + { + ["Name"] = a.FileName, + ["Content"] = a.Base64, + ["ContentType"] = a.ContentType, + }).ToList(); + + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.postmarkapp.com/email") { Content = ProviderHttp.Json(body) }; + req.Headers.Add("X-Postmark-Server-Token", token); + req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var (status, json) = await ProviderHttp.SendAsync(http, req, Name, ct); + + string? id = null; var errorCode = 0; string? msg = null; + try + { + var r = JsonDocument.Parse(json).RootElement; + if (r.TryGetProperty("MessageID", out var m)) id = m.GetString(); + if (r.TryGetProperty("ErrorCode", out var e)) errorCode = e.GetInt32(); + if (r.TryGetProperty("Message", out var mm)) msg = mm.GetString(); + } + catch { /* best-effort parse */ } + + if (errorCode != 0) throw new InvalidOperationException($"Postmark ErrorCode {errorCode}: {msg}"); + return RelayResult.Success(id, $"HTTP {status} - Postmark MessageID: {id}"); + } +} diff --git a/src/Dispatch.Providers/ProviderHttp.cs b/src/Dispatch.Providers/ProviderHttp.cs new file mode 100644 index 00000000..fa01c237 --- /dev/null +++ b/src/Dispatch.Providers/ProviderHttp.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using Dispatch.Core.Providers; +using MimeKit; + +namespace Dispatch.Providers; + +/// Shared helpers for the HTTP/JSON relay providers (Postmark, Resend, SparkPost, SMTP2GO). +internal static class ProviderHttp +{ + public static string From(RelayMessage m) => m.Message.From.Mailboxes.FirstOrDefault()?.Address ?? m.FromAddress; + + public static IReadOnlyList Recipients(RelayMessage m) => + m.ToAddresses.Count > 0 ? m.ToAddresses : m.Message.To.Mailboxes.Select(x => x.Address).ToArray(); + + /// To/Cc/Bcc recipients for structured-JSON providers. To/Cc are taken from the visible message + /// headers (intersected with the actual envelope so we never deliver beyond it); Bcc is every envelope + /// recipient not named in a visible header - so blind recipients are delivered without being exposed in + /// the To/Cc the others see. Providers that post raw MIME (Mailgun, SES, SparkPost) don't need this - the + /// envelope carries the real recipients and the MIME headers (which omit Bcc) are sent verbatim. + public sealed record RecipientSet(IReadOnlyList To, IReadOnlyList Cc, IReadOnlyList Bcc); + + public static RecipientSet SplitRecipients(RelayMessage m) + { + var envelope = m.ToAddresses.Count > 0 + ? m.ToAddresses + : m.Message.To.Mailboxes.Select(x => x.Address).ToList(); + var envSet = new HashSet(envelope, StringComparer.OrdinalIgnoreCase); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + var to = new List(); + foreach (var a in m.Message.To.Mailboxes.Select(x => x.Address)) + if (envSet.Contains(a) && seen.Add(a)) to.Add(a); + + var cc = new List(); + foreach (var a in m.Message.Cc.Mailboxes.Select(x => x.Address)) + if (envSet.Contains(a) && seen.Add(a)) cc.Add(a); + + var bcc = new List(); + foreach (var a in envelope) + if (seen.Add(a)) bcc.Add(a); + + // No visible recipients at all (header-less / undisclosed): treat the envelope as To so the send still + // has a primary recipient rather than being bcc-only, which some provider APIs reject. + if (to.Count == 0 && cc.Count == 0) return new RecipientSet(bcc, [], []); + return new RecipientSet(to, cc, bcc); + } + + public static string Subject(RelayMessage m) => m.Message.Subject ?? ""; + public static string? Html(RelayMessage m) => m.Message.HtmlBody; + public static string? Text(RelayMessage m) => m.Message.TextBody; + + /// One attachment decoded for a structured-JSON send: filename, raw bytes, and content-type. + public sealed record OutgoingAttachment(string FileName, byte[] Content, string ContentType) + { + public string Base64 => Convert.ToBase64String(Content); + } + + /// Extracts the message's attachments (decoded) so structured-JSON providers can forward them. + /// Providers that send raw MIME (SES, SparkPost) don't need this - attachments ride along in the MIME. + public static IReadOnlyList Attachments(RelayMessage m) + { + var list = new List(); + foreach (var att in m.Message.Attachments) + { + if (att is not MimePart part || part.Content is null) continue; + using var ms = new MemoryStream(); + part.Content.DecodeTo(ms); + list.Add(new OutgoingAttachment( + part.FileName ?? "attachment", + ms.ToArray(), + part.ContentType?.MimeType ?? "application/octet-stream")); + } + return list; + } + + public static async Task RawMimeAsync(RelayMessage m, CancellationToken ct) + { + using var ms = new MemoryStream(); + await m.Message.WriteToAsync(ms, ct); + return Encoding.UTF8.GetString(ms.ToArray()); + } + + public static HttpContent Json(object body) => + new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + + /// Sends the request, mapping transport errors and 429/5xx to + /// and other non-2xx to a permanent . Returns (status, body). + public static async Task<(int Status, string Body)> SendAsync(HttpClient http, HttpRequestMessage req, string provider, CancellationToken ct) + { + HttpResponseMessage res; + try { res = await http.SendAsync(req, ct); } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + throw new TransientRelayException($"{provider} request failed: {ex.Message}", ex); + } + + var body = await res.Content.ReadAsStringAsync(ct); + if (res.IsSuccessStatusCode) return ((int)res.StatusCode, body); + + var detail = $"{provider} {(int)res.StatusCode}: {body}"; + if ((int)res.StatusCode == 429 || ((int)res.StatusCode >= 500 && (int)res.StatusCode < 600)) + throw new TransientRelayException(detail); + throw new InvalidOperationException(detail); + } + + public static string? Setting(RelayConfig c, string key) => c.Settings.TryGetValue(key, out var v) ? v : null; + + public static string Require(RelayConfig c, string provider, string key) + { + var v = Setting(c, key); + if (string.IsNullOrWhiteSpace(v)) + throw new InvalidOperationException($"{provider} relay '{key}' is not configured."); + return v; + } +} diff --git a/src/Dispatch.Providers/RelayProviderFactory.cs b/src/Dispatch.Providers/RelayProviderFactory.cs new file mode 100644 index 00000000..f27d1d3e --- /dev/null +++ b/src/Dispatch.Providers/RelayProviderFactory.cs @@ -0,0 +1,32 @@ +using System.Net.Http; +using Dispatch.Core.Providers; +using Dispatch.Core.Spool; + +namespace Dispatch.Providers; + +/// Builds an from a (spec §8). +public sealed class RelayProviderFactory( + IHttpClientFactory httpClientFactory, + ISendGridClientFactory sendGridClientFactory, + IEmailClientFactory emailClientFactory, + SpoolDirectory spool) : IRelayProviderFactory +{ + public IRelayProvider Build(RelayConfig config) => config.Provider switch + { + RelayProviderType.Unconfigured => throw new InvalidOperationException( + "No relay provider is configured. Choose a provider under Relays before sending mail."), + RelayProviderType.Local => new LocalProvider(spool.CapturedDir), + RelayProviderType.Smtp => new SmtpProvider(config), + RelayProviderType.Mailgun => new MailgunProvider(config, httpClientFactory.CreateClient("mailgun")), + RelayProviderType.SendGrid => new SendGridProvider(config, sendGridClientFactory), + RelayProviderType.AzureCommunication => new AzureProvider(config, emailClientFactory), + RelayProviderType.AmazonSes => new AmazonSesProvider(config), + RelayProviderType.Postmark => new PostmarkProvider(config, httpClientFactory.CreateClient("postmark")), + RelayProviderType.Resend => new ResendProvider(config, httpClientFactory.CreateClient("resend")), + RelayProviderType.SparkPost => new SparkPostProvider(config, httpClientFactory.CreateClient("sparkpost")), + RelayProviderType.Smtp2Go => new Smtp2GoProvider(config, httpClientFactory.CreateClient("smtp2go")), + RelayProviderType.Maileroo => new MailerooProvider(config, httpClientFactory.CreateClient("maileroo")), + RelayProviderType.Bird => new BirdProvider(config, httpClientFactory.CreateClient("bird")), + _ => throw new NotSupportedException($"Relay provider '{config.Provider}' is not supported."), + }; +} diff --git a/src/Dispatch.Providers/ResendProvider.cs b/src/Dispatch.Providers/ResendProvider.cs new file mode 100644 index 00000000..b532eff7 --- /dev/null +++ b/src/Dispatch.Providers/ResendProvider.cs @@ -0,0 +1,55 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using Dispatch.Core.Providers; + +namespace Dispatch.Providers; + +/// +/// Resend upstream relay via the Emails API (spec §8). Structured send (from/to/subject/html/text), Bearer +/// auth. Settings: ApiKey. Returns { id }. +/// +public sealed class ResendProvider(RelayConfig config, HttpClient http) : IRelayProvider +{ + public string Name => "Resend"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var apiKey = ProviderHttp.Require(config, Name, "ApiKey"); + var html = ProviderHttp.Html(message); + var text = ProviderHttp.Text(message); + + var rcpt = ProviderHttp.SplitRecipients(message); + var body = new Dictionary + { + ["from"] = ProviderHttp.From(message), + ["to"] = rcpt.To, + ["subject"] = ProviderHttp.Subject(message), + }; + if (rcpt.Cc.Count > 0) body["cc"] = rcpt.Cc; + if (rcpt.Bcc.Count > 0) body["bcc"] = rcpt.Bcc; + if (html is not null) body["html"] = html; + if (text is not null) body["text"] = text; + if (html is null && text is null) body["text"] = ""; + + var attachments = ProviderHttp.Attachments(message); + if (attachments.Count > 0) + body["attachments"] = attachments.Select(a => new Dictionary + { + ["filename"] = a.FileName, + ["content"] = a.Base64, // Resend accepts base64-encoded content + ["content_type"] = a.ContentType, + }).ToList(); + + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.resend.com/emails") { Content = ProviderHttp.Json(body) }; + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + var (status, json) = await ProviderHttp.SendAsync(http, req, Name, ct); + + string? id = null; + try { if (JsonDocument.Parse(json).RootElement.TryGetProperty("id", out var m)) id = m.GetString(); } + catch { /* best-effort */ } + + return RelayResult.Success(id, $"HTTP {status} - Resend id: {id}"); + } +} diff --git a/src/Dispatch.Providers/SendGridProvider.cs b/src/Dispatch.Providers/SendGridProvider.cs new file mode 100644 index 00000000..92e551ae --- /dev/null +++ b/src/Dispatch.Providers/SendGridProvider.cs @@ -0,0 +1,82 @@ +using Dispatch.Core.Providers; +using MimeKit; +using SendGrid; +using SendGrid.Helpers.Mail; + +namespace Dispatch.Providers; + +/// +/// SendGrid upstream relay via the official Web API v3 SDK (spec §8.2). Maps the parsed message's +/// from/to/subject/bodies/attachments onto a SendGrid message. Settings: ApiKey. 429/5xx are transient. +/// +public sealed class SendGridProvider(RelayConfig config, ISendGridClientFactory clientFactory) : IRelayProvider +{ + public string Name => "SendGrid"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var apiKey = config.Settings.TryGetValue("ApiKey", out var k) ? k : null; + if (string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException("SendGrid relay 'ApiKey' is not configured."); + + var mime = message.Message; + var fromMailbox = mime.From.Mailboxes.FirstOrDefault() + ?? throw new InvalidOperationException("Message has no From address."); + + var msg = new SendGridMessage(); + msg.SetFrom(new EmailAddress(fromMailbox.Address, fromMailbox.Name)); + msg.SetSubject(mime.Subject ?? ""); + + var rcpt = ProviderHttp.SplitRecipients(message); + foreach (var to in rcpt.To) msg.AddTo(new EmailAddress(to)); + foreach (var cc in rcpt.Cc) msg.AddCc(new EmailAddress(cc)); + foreach (var bcc in rcpt.Bcc) msg.AddBcc(new EmailAddress(bcc)); + + if (!string.IsNullOrEmpty(mime.TextBody)) msg.AddContent(MimeType.Text, mime.TextBody); + if (!string.IsNullOrEmpty(mime.HtmlBody)) msg.AddContent(MimeType.Html, mime.HtmlBody); + + foreach (var part in mime.Attachments.OfType()) + { + if (part.Content is null) continue; + using var ms = new MemoryStream(); + part.Content.DecodeTo(ms); + msg.AddAttachment(part.FileName ?? "attachment", + Convert.ToBase64String(ms.ToArray()), part.ContentType?.MimeType); + } + + var client = clientFactory.Create(apiKey); + Response response; + try + { + response = await client.SendEmailAsync(msg, ct); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + throw new TransientRelayException($"SendGrid request failed: {ex.Message}", ex); + } + + var status = (int)response.StatusCode; + if (status is < 200 or >= 300) + { + var detail = $"SendGrid {status}: {await response.Body.ReadAsStringAsync(ct)}"; + if (status is 429 or >= 500) throw new TransientRelayException(detail); + throw new InvalidOperationException(detail); + } + + response.Headers.TryGetValues("X-Message-Id", out var ids); + var messageId = ids?.FirstOrDefault(); + // Spec §11.6 detail format. + return RelayResult.Success(messageId, $"HTTP {status} Accepted - X-Message-Id: {messageId}"); + } +} + +/// Indirection so the SendGrid client can be faked in tests. +public interface ISendGridClientFactory +{ + ISendGridClient Create(string apiKey); +} + +public sealed class SendGridClientFactory : ISendGridClientFactory +{ + public ISendGridClient Create(string apiKey) => new SendGridClient(apiKey); +} diff --git a/src/Dispatch.Providers/Smtp2GoProvider.cs b/src/Dispatch.Providers/Smtp2GoProvider.cs new file mode 100644 index 00000000..ef386b03 --- /dev/null +++ b/src/Dispatch.Providers/Smtp2GoProvider.cs @@ -0,0 +1,79 @@ +using System.Net.Http; +using System.Text.Json; +using Dispatch.Core.Providers; + +namespace Dispatch.Providers; + +/// +/// SMTP2GO upstream relay via the v3 email/send API (spec §8). Structured send (sender/to/subject/bodies); +/// the API key travels in the JSON body. Settings: ApiKey. Returns data.email_id. +/// +public sealed class Smtp2GoProvider(RelayConfig config, HttpClient http) : IRelayProvider +{ + public string Name => "SMTP2GO"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var apiKey = ProviderHttp.Require(config, Name, "ApiKey"); + var html = ProviderHttp.Html(message); + var text = ProviderHttp.Text(message); + + var rcpt = ProviderHttp.SplitRecipients(message); + var body = new Dictionary + { + ["api_key"] = apiKey, + ["sender"] = ProviderHttp.From(message), + ["to"] = rcpt.To, + ["subject"] = ProviderHttp.Subject(message), + }; + if (rcpt.Cc.Count > 0) body["cc"] = rcpt.Cc; + if (rcpt.Bcc.Count > 0) body["bcc"] = rcpt.Bcc; + if (html is not null) body["html_body"] = html; + if (text is not null) body["text_body"] = text; + if (html is null && text is null) body["text_body"] = ""; + + var attachments = ProviderHttp.Attachments(message); + if (attachments.Count > 0) + body["attachments"] = attachments.Select(a => new Dictionary + { + ["filename"] = a.FileName, + ["fileblob"] = a.Base64, + ["mimetype"] = a.ContentType, + }).ToList(); + + using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.smtp2go.com/v3/email/send") { Content = ProviderHttp.Json(body) }; + + var (status, json) = await ProviderHttp.SendAsync(http, req, Name, ct); + + // SMTP2GO returns HTTP 200 even when it rejects the message - the real outcome lives in `data` + // (succeeded/failed counts, or an error/field_validation_errors). Treat anything that isn't a + // confirmed success as a permanent failure so it's logged and surfaced rather than silently "sent". + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) + throw new InvalidOperationException($"SMTP2GO returned an unexpected response (HTTP {status}): {json}"); + + if (data.TryGetProperty("error", out var err) && err.ValueKind == JsonValueKind.String) + { + var code = data.TryGetProperty("error_code", out var ec) ? ec.GetString() : null; + var fields = data.TryGetProperty("field_validation_errors", out var fve) ? fve.GetRawText() : null; + throw new InvalidOperationException( + $"SMTP2GO rejected the message: {err.GetString()}" + + (code is not null ? $" (code {code})" : "") + + (fields is not null ? $" - {fields}" : "")); + } + + var succeeded = data.TryGetProperty("succeeded", out var s) && s.TryGetInt32(out var sv) ? sv : 0; + if (succeeded < 1) + { + var failures = data.TryGetProperty("failures", out var f) && f.ValueKind == JsonValueKind.Array + ? string.Join("; ", f.EnumerateArray().Select(x => x.ToString())) + : null; + throw new InvalidOperationException( + $"SMTP2GO did not accept the message (succeeded=0){(string.IsNullOrWhiteSpace(failures) ? $": {json}" : $": {failures}")}"); + } + + string? id = data.TryGetProperty("email_id", out var m) ? m.GetString() : null; + return RelayResult.Success(id, $"HTTP {status} - SMTP2GO succeeded={succeeded}, email_id: {id}"); + } +} diff --git a/src/Dispatch.Providers/SmtpProvider.cs b/src/Dispatch.Providers/SmtpProvider.cs new file mode 100644 index 00000000..e583fa6f --- /dev/null +++ b/src/Dispatch.Providers/SmtpProvider.cs @@ -0,0 +1,93 @@ +using Dispatch.Core.Providers; +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using System.Net.Sockets; + +namespace Dispatch.Providers; + +/// +/// Generic SMTP upstream relay via MailKit (spec §8.4) - for AWS SES SMTP, Office 365, Postfix, +/// or any smart host. Settings: Host, Port, Username, Password, TlsMode (None|Auto|StartTls|SslOnConnect). +/// 4xx / connection errors are mapped to so they are retried. +/// +public sealed class SmtpProvider : IRelayProvider +{ + private readonly RelayConfig _config; + + public SmtpProvider(RelayConfig config) => _config = config; + + public string Name => "Smtp"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var host = Setting("Host"); + if (string.IsNullOrWhiteSpace(host)) + throw new InvalidOperationException("SMTP relay 'Host' is not configured."); + + var port = int.TryParse(Setting("Port"), out var p) ? p : 25; + var user = Setting("Username"); + var pass = Setting("Password"); + var secure = ParseTls(Setting("TlsMode")); + + using var client = new SmtpClient(); + try + { + await client.ConnectAsync(host, port, secure, ct); + if (!string.IsNullOrEmpty(user)) + await client.AuthenticateAsync(user, pass ?? "", ct); + + // Deliver to the SMTP envelope recipients (MAIL FROM / RCPT TO), not whatever the message headers + // happen to list - otherwise MailKit derives recipients from To/Cc/Bcc headers and silently drops + // Bcc recipients (which are envelope-only, never in the headers) and any header/envelope mismatch. + var recipients = (message.ToAddresses.Count > 0 + ? message.ToAddresses + : message.Message.To.Mailboxes.Select(m => m.Address)) + .Select(MailboxAddress.Parse).ToList(); + + string response; + if (recipients.Count > 0) + { + var sender = !string.IsNullOrWhiteSpace(message.FromAddress) + ? MailboxAddress.Parse(message.FromAddress) + : message.Message.From.Mailboxes.FirstOrDefault() + ?? throw new InvalidOperationException("Message has no sender address."); + response = await client.SendAsync(message.Message, sender, recipients, ct); + } + else + { + response = await client.SendAsync(message.Message, ct); + } + await client.DisconnectAsync(quit: true, ct); + + // Spec §11.6 detail format (250 + server response line). + return RelayResult.Success(detail: $"250 {response}"); + } + catch (Exception ex) when (IsTransient(ex)) + { + throw new TransientRelayException(ex.Message, ex); + } + } + + private string? Setting(string key) => + _config.Settings.TryGetValue(key, out var v) ? v : null; + + internal static SecureSocketOptions ParseTls(string? mode) => mode?.Trim().ToLowerInvariant() switch + { + "none" => SecureSocketOptions.None, + "ssl" or "sslonconnect" => SecureSocketOptions.SslOnConnect, + "starttls" => SecureSocketOptions.StartTls, + "starttlswhenavailable" => SecureSocketOptions.StartTlsWhenAvailable, + _ => SecureSocketOptions.Auto, + }; + + internal static bool IsTransient(Exception ex) => ex switch + { + SmtpCommandException sce => (int)sce.StatusCode is >= 400 and < 500, + SmtpProtocolException => true, + SocketException => true, + IOException => true, + TimeoutException => true, + _ => false, + }; +} diff --git a/src/Dispatch.Providers/SparkPostProvider.cs b/src/Dispatch.Providers/SparkPostProvider.cs new file mode 100644 index 00000000..95d4cc2f --- /dev/null +++ b/src/Dispatch.Providers/SparkPostProvider.cs @@ -0,0 +1,39 @@ +using System.Net.Http; +using System.Text.Json; +using Dispatch.Core.Providers; + +namespace Dispatch.Providers; + +/// +/// SparkPost upstream relay via the Transmissions API (spec §8). Posts the raw MIME (email_rfc822) so +/// attachments + headers are preserved. Settings: ApiKey, optional Region (US|EU). Auth via the API key header. +/// +public sealed class SparkPostProvider(RelayConfig config, HttpClient http) : IRelayProvider +{ + public string Name => "SparkPost"; + + public async Task SendAsync(RelayMessage message, CancellationToken ct) + { + var apiKey = ProviderHttp.Require(config, Name, "ApiKey"); + var region = (ProviderHttp.Setting(config, "Region") ?? "US").Trim().ToUpperInvariant(); + var baseUrl = region == "EU" ? "https://api.eu.sparkpost.com" : "https://api.sparkpost.com"; + var mime = await ProviderHttp.RawMimeAsync(message, ct); + + var body = new Dictionary + { + ["recipients"] = ProviderHttp.Recipients(message).Select(r => new { address = r }).ToArray(), + ["content"] = new Dictionary { ["email_rfc822"] = mime }, + }; + + using var req = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/api/v1/transmissions") { Content = ProviderHttp.Json(body) }; + req.Headers.Add("Authorization", apiKey); + + var (status, json) = await ProviderHttp.SendAsync(http, req, Name, ct); + + string? id = null; + try { if (JsonDocument.Parse(json).RootElement.TryGetProperty("results", out var r) && r.TryGetProperty("id", out var m)) id = m.GetString(); } + catch { /* best-effort */ } + + return RelayResult.Success(id, $"HTTP {status} - SparkPost id: {id}"); + } +} diff --git a/src/Dispatch.Service/CidrMailboxFilter.cs b/src/Dispatch.Service/CidrMailboxFilter.cs new file mode 100644 index 00000000..9571502d --- /dev/null +++ b/src/Dispatch.Service/CidrMailboxFilter.cs @@ -0,0 +1,234 @@ +using Dispatch.Core.Audit; +using Dispatch.Core.Configuration; +using Dispatch.Core.Counters; +using Dispatch.Core.Logging; +using Dispatch.Core.Maintenance; +using Dispatch.Core.Routing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SmtpServer; +using SmtpServer.Mail; +using SmtpServer.Net; +using SmtpServer.Protocol; +using SmtpServer.Storage; +using System.Net; + +namespace Dispatch.Service; + +/// +/// Application-layer access control (spec §5.3, §14.2): refuses MAIL FROM when the source IP is outside +/// the configured allow-list, enforces the global max message size at MAIL FROM, and enforces the +/// per-relay size limit at RCPT TO (before DATA) by running the routing engine. Denied attempts are counted +/// and, when is enabled, written to relay_log (spec §6.6/§9.2). +/// +public sealed class CidrMailboxFilter : IMailboxFilter +{ + /// Session property key holding the SIZE= declared in MAIL FROM (int; 0 if not declared). + internal const string DeclaredSizeKey = "Dispatch.DeclaredSize"; + + private readonly ConfigCache _config; + private readonly ICounterRepository _counters; + private readonly IRelayResolver _routing; + private readonly IntakeState _intake; + private readonly ConnectionTracker _connections; + private readonly ILogRepository _logRepo; + private readonly ILoggingSettings _loggingSettings; + private readonly Dispatch.Core.Audit.IAuditLog? _audit; + private readonly ILogger _log; + + // Memoised CIDR parse - reparsed only when the allow-list changes in the config table (spec §12.5 live). + private readonly Lock _cidrLock = new(); + private string? _cidrKey; + private IPNetwork[] _cidrNetworks = []; + + public CidrMailboxFilter( + ConfigCache config, + ICounterRepository counters, + IRelayResolver routing, + IntakeState intake, + ConnectionTracker connections, + ILogRepository logRepo, + ILoggingSettings loggingSettings, + ILogger log, + Dispatch.Core.Audit.IAuditLog? audit = null) + { + _config = config; + _counters = counters; + _routing = routing; + _intake = intake; + _connections = connections; + _logRepo = logRepo; + _loggingSettings = loggingSettings; + _log = log; + _audit = audit; + } + + private IPNetwork[] AllowedNetworks(string[] cidrs) + { + var key = string.Join(",", cidrs); + lock (_cidrLock) + { + if (key == _cidrKey) return _cidrNetworks; + _cidrNetworks = cidrs + .Select(c => IPNetwork.TryParse(c, out var n) ? (IPNetwork?)n : null) + .Where(n => n.HasValue).Select(n => n!.Value).ToArray(); + _cidrKey = key; + return _cidrNetworks; + } + } + + public async Task CanAcceptFromAsync( + ISessionContext context, IMailbox from, int size, CancellationToken cancellationToken) + { + // Disk back-pressure (spec §14.1): reject when suspended so senders retry; delay when throttled + // to slow the inbound rate. Checked first - under disk pressure we don't want to do more work. + switch (_intake.Level) + { + case IntakeLevel.Suspended: + await DenyAsync(context, from.AsAddress(), null, + "Intake suspended (spool disk critically low)", cancellationToken); + _log.LogWarning("Rejecting MAIL FROM {From}: intake suspended (spool disk critically low)", + from.AsAddress()); + // Transient 452 (RFC 5321) so senders retry rather than treating it as a permanent failure (spec §14.1). + throw new SmtpResponseException( + new SmtpResponse(SmtpReplyCode.InsufficientStorage, "Insufficient system storage, try again later")); + case IntakeLevel.Throttled: + try { await Task.Delay(IntakeState.ThrottleDelay, cancellationToken); } + catch (OperationCanceledException) { return false; } + break; + } + + // Live settings from the config cache (spec §12.5): edits in the web UI apply on the next connection. + var listener = _config.Listener(); + + // Max concurrent connections (spec §5.3). The library accepts the TCP connection (counted on + // SessionCreated) before MAIL FROM, so we refuse here with a transient 421 once over the cap. + if (listener.MaxConnections > 0 && _connections.Active > listener.MaxConnections) + { + await DenyAsync(context, from.AsAddress(), null, + $"Too many concurrent connections ({_connections.Active} > {listener.MaxConnections})", cancellationToken); + _log.LogWarning("Rejecting MAIL FROM {From}: over connection cap ({Active}/{Max})", + from.AsAddress(), _connections.Active, listener.MaxConnections); + throw new SmtpResponseException( + new SmtpResponse(SmtpReplyCode.ServiceUnavailable, "Too many concurrent connections, try again later")); + } + + // Capture the declared SIZE= for the per-relay check at RCPT TO (spec §14.2). + context.Properties[DeclaredSizeKey] = size; + + if (listener.MaxMessageBytes > 0 && size > listener.MaxMessageBytes) + { + await DenyAsync(context, from.AsAddress(), null, + $"Declared size {size} exceeds global limit {listener.MaxMessageBytes}", cancellationToken); + _log.LogWarning("Rejecting MAIL FROM {From}: size {Size} exceeds limit {Limit}", + from.AsAddress(), size, listener.MaxMessageBytes); + // 552 (RFC 5321 §4.2.3): message exceeds the size limit - distinct from a 550 mailbox rejection. + throw new SmtpResponseException(new SmtpResponse(SmtpReplyCode.SizeLimitExceeded, + $"5.3.4 Message size {size} exceeds the {listener.MaxMessageBytes}-byte limit")); + } + + // Closed model (spec §17.10): only source IPs in the allow-list may connect. An empty list denies + // everyone - add ranges in Access Control to open it up. To allow all sources, add 0.0.0.0/0 + ::/0. + // Rejections use 550 5.7.1 (access denied) with a clear reason rather than the library's generic + // "mailbox unavailable", which misleads operators into thinking it's a recipient problem. + var allowed = AllowedNetworks(listener.EffectiveAllowedCidrs); + var ip = RemoteIp(context); + if (allowed.Length == 0) + { + await DenyAsync(context, from.AsAddress(), null, "SMTP allow-list is empty - all sources denied", cancellationToken); + _log.LogWarning("Denied SMTP connection: allow-list is empty (closed by default; add ranges in Access Control)"); + if (_audit is not null) await _audit.Audit("Access", "SMTP connection denied: allow-list is empty", "Warning", sourceIp: ip?.ToString()); + throw AccessDenied("no source IPs are allowed yet - add your network under Access Control → SMTP listener"); + } + if (ip is null) + { + await DenyAsync(context, from.AsAddress(), null, "Source IP unavailable - cannot verify allow-list", cancellationToken); + _log.LogWarning("Denied SMTP connection: source IP unavailable, cannot match allow-list"); + throw AccessDenied("could not determine your source IP to check the allow-list"); + } + + var test = ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip; + var permitted = allowed.Any(n => n.Contains(test) || n.Contains(ip)); + + if (!permitted) + { + await DenyAsync(context, from.AsAddress(), null, $"Source IP {ip} not in allow-list", cancellationToken); + _log.LogWarning("Denied connection from {Ip} (not in allow-list)", ip); + if (_audit is not null) await _audit.Audit("Access", $"SMTP connection denied: {ip} not in allow-list", "Warning", sourceIp: ip.ToString()); + throw AccessDenied($"source IP {ip} is not in the allow-list - add it under Access Control → SMTP listener"); + } + + return true; + } + + // 550 5.7.1 with a clear, actionable reason for an allow-list rejection. + private static SmtpResponseException AccessDenied(string reason) => + new(new SmtpResponse(SmtpReplyCode.MailboxUnavailable, $"5.7.1 Access denied: {reason}")); + + public async Task CanDeliverToAsync( + ISessionContext context, IMailbox to, IMailbox from, CancellationToken cancellationToken) + { + // No declared SIZE= → can't check before DATA; the actual-size check after DATA is the fallback. + var declaredSize = context.Properties.TryGetValue(DeclaredSizeKey, out var s) && s is int i ? i : 0; + if (declaredSize <= 0) + return true; + + // Routing is now resolvable (MAIL FROM + first RCPT TO known): enforce the per-relay ceiling. + var relay = await _routing.ResolveAsync(from.AsAddress(), [to.AsAddress()], cancellationToken); + var limit = relay.Config.EffectiveMaxMessageBytes; + + if (limit > 0 && declaredSize > limit) + { + await DenyAsync(context, from.AsAddress(), to.AsAddress(), + $"Declared size {declaredSize} exceeds relay \"{relay.Name}\" limit {limit}", cancellationToken); + _log.LogWarning( + "Rejecting RCPT TO {To}: declared size {Size} exceeds relay \"{Relay}\" limit {Limit}", + to.AsAddress(), declaredSize, relay.Name, limit); + throw new SmtpResponseException(new SmtpResponse(SmtpReplyCode.SizeLimitExceeded, + $"5.3.4 Message size {declaredSize} exceeds relay \"{relay.Name}\" limit {limit}")); + } + + return true; + } + + /// + /// Records a denial: always increments the Denied counter, and (when enabled) writes a Denied relay_log + /// row so refusals are visible in the message log, not just the dashboard counter. Both are best-effort - + /// a logging/counter failure must never turn a refusal into an acceptance. + /// + private async Task DenyAsync(ISessionContext context, string from, string? to, string reason, CancellationToken ct) + { + try { await _counters.IncrementAsync(0, CounterField.Denied, ct); } + catch (Exception ex) { _log.LogError(ex, "Denied-counter increment failed"); } + + try + { + if (!await _loggingSettings.LogDeniedAsync(ct)) return; + await _logRepo.InsertAsync(new RelayLogEntry + { + Event = "Denied", + Status = "Denied", + FromAddress = from, + FromDomain = Domain(from), + ToAddresses = to is null ? [] : [to], + ToDomain = to is null ? "" : Domain(to), + Error = reason, + IngestSource = "SMTP", + SourceIp = RemoteIp(context)?.ToString(), + }, ct); + } + catch (Exception ex) { _log.LogError(ex, "Denied relay_log insert failed (refusal unaffected)"); } + } + + private static string Domain(string address) + { + var at = address.LastIndexOf('@'); + return at >= 0 && at < address.Length - 1 ? address[(at + 1)..] : ""; + } + + private static IPAddress? RemoteIp(ISessionContext context) => + context.Properties.TryGetValue(EndpointListener.RemoteEndPointKey, out var ep) + && ep is IPEndPoint ipep + ? ipep.Address + : null; +} diff --git a/src/Dispatch.Service/ConfiguredUserAuthenticator.cs b/src/Dispatch.Service/ConfiguredUserAuthenticator.cs new file mode 100644 index 00000000..129ddb48 --- /dev/null +++ b/src/Dispatch.Service/ConfiguredUserAuthenticator.cs @@ -0,0 +1,38 @@ +using System.Net; +using Dispatch.Core.Audit; +using Dispatch.Core.Smtp; +using SmtpServer; +using SmtpServer.Authentication; +using SmtpServer.Net; + +namespace Dispatch.Service; + +/// SMTP AUTH against the configured credential allow-list (spec §5.3, §19.2), with a per-source-IP +/// brute-force lockout (spec §17.10): once an IP is locked out, AUTH is refused without hitting the store. +public sealed class ConfiguredUserAuthenticator(ISmtpCredentialRepository credentials, SmtpAuthThrottle throttle, IAuditLog? audit = null) + : IUserAuthenticator +{ + public async Task AuthenticateAsync(ISessionContext context, string user, string password, CancellationToken cancellationToken) + { + var ip = RemoteIp(context)?.ToString() ?? "unknown"; + if (throttle.IsLocked(ip)) + { + if (audit is not null) await audit.Audit("SmtpAuth", "SMTP AUTH blocked (locked out)", "Warning", actor: user, sourceIp: ip); + return false; + } + + var ok = await credentials.VerifyAsync(user, password, cancellationToken); + if (ok) throttle.RecordSuccess(ip); + else + { + throttle.RecordFailure(ip); + if (audit is not null) await audit.Audit("SmtpAuth", $"SMTP AUTH failed for \"{user}\"", "Warning", actor: user, sourceIp: ip); + } + return ok; + } + + private static IPAddress? RemoteIp(ISessionContext context) => + context.Properties.TryGetValue(EndpointListener.RemoteEndPointKey, out var ep) && ep is IPEndPoint ipep + ? ipep.Address + : null; +} diff --git a/src/Dispatch.Service/ConnectionTracker.cs b/src/Dispatch.Service/ConnectionTracker.cs new file mode 100644 index 00000000..993b17d6 --- /dev/null +++ b/src/Dispatch.Service/ConnectionTracker.cs @@ -0,0 +1,21 @@ +namespace Dispatch.Service; + +/// +/// Tracks the number of live SMTP sessions (spec §5.3 max concurrent connections). Incremented on +/// SessionCreated and decremented on session completion/fault/cancellation by ; +/// reads to refuse MAIL FROM once the cap is exceeded. +/// +public sealed class ConnectionTracker +{ + private int _active; + + public int Active => Volatile.Read(ref _active); + + public void Increment() => Interlocked.Increment(ref _active); + + public void Decrement() + { + // Never let the counter drift below zero if a decrement is ever double-fired. + if (Interlocked.Decrement(ref _active) < 0) Interlocked.Exchange(ref _active, 0); + } +} diff --git a/src/Dispatch.Service/Dispatch.Service.csproj b/src/Dispatch.Service/Dispatch.Service.csproj new file mode 100644 index 00000000..167a3fad --- /dev/null +++ b/src/Dispatch.Service/Dispatch.Service.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + dotnet-Dispatch.Service-2736bf2c-2aa9-41f9-b8a2-cef53a5617e4 + + + + + + + + + + + + + + + + + diff --git a/src/Dispatch.Service/Program.cs b/src/Dispatch.Service/Program.cs new file mode 100644 index 00000000..9a703c84 --- /dev/null +++ b/src/Dispatch.Service/Program.cs @@ -0,0 +1,351 @@ +using Dispatch.Core.Audit; +using Dispatch.Core.Configuration; +using Dispatch.Core.Counters; +using Dispatch.Core.Maintenance; +using Dispatch.Core.Providers; +using Dispatch.Core.Relays; +using Dispatch.Core.Routing; +using Dispatch.Core.Spool; +using Dispatch.Data; +using Dispatch.Data.Repositories; +using Dispatch.Providers; +using Dispatch.Service; +using Dispatch.Web; +using Dispatch.Web.Endpoints; +using Dispatch.Web.Ingestion; +using Dispatch.Web.Realtime; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Serilog; +using System.Text; + +var logDirectory = Environment.GetEnvironmentVariable("DISPATCH_LOG_DIR") ?? "logs"; +Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console() + .WriteTo.File(Path.Combine(logDirectory, "dispatch-.log"), + rollingInterval: RollingInterval.Day, retainedFileCountLimit: 31) // spec §13: rolling daily with retention + .CreateLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + builder.Host.UseSerilog(); + // Integrate with the host service manager so it reports "running" correctly: Windows SCM (otherwise the + // MSI's service start times out → 1603) and systemd Type=notify. Both are no-ops when run interactively. + builder.Host.UseWindowsService(); + builder.Host.UseSystemd(); + + // CLI: `Dispatch.Service reset-admin-password` resets the dashboard admin password and exits - for + // operators locked out locally. Runs before any web setup; uses the same config (appsettings/env). + if (args.Contains("reset-admin-password", StringComparer.OrdinalIgnoreCase)) + return await ResetAdminPasswordAsync(builder.Configuration); + + // Durable location for the at-rest encryption key (all platforms): DISPATCH_KEY_DIR or the content root. + // The Windows installer points DISPATCH_KEY_DIR at the Program Files install dir; Linux/Docker use the + // content root / spool dir. Must be set before any encrypted config is read/written. + SecureConfig.UseKeyDirectory( + Environment.GetEnvironmentVariable("DISPATCH_KEY_DIR") ?? builder.Environment.ContentRootPath); + + // --- Bootstrap (spec §12.1, §12.6, §12.8) ------------------------------------------------- + // appsettings.json holds ONLY the DB connection string and the Web UI TLS cert. Everything else lives + // in the SQL config table. SQL must be reachable at startup: initialise the schema, seed default config + // on first run, and load the ConfigCache - the single source of truth - before configuring listeners. + var connectionString = builder.Configuration.GetConnectionString("DispatchLog") + ?? throw new InvalidOperationException("ConnectionStrings:DispatchLog is not configured."); + + var bootstrapFactory = new SqlConnectionFactory(connectionString); + var bootstrapRepo = new SqlConfigRepository(bootstrapFactory); + var configCache = new ConfigCache(); + try + { + await new DatabaseInitializer(bootstrapFactory, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance).InitializeAsync(); + await ConfigDefaults.SeedAsync(bootstrapRepo); + await configCache.LoadAsync(bootstrapRepo); + } + catch (Exception ex) + { + // §12.8: without config there is nothing safe to do - log clearly and exit non-zero so the + // service manager retries on its configured restart interval. + Log.Fatal(ex, "Dispatch cannot start: the SQL configuration database is unreachable."); + return 1; + } + + // Always have a shared TLS certificate on hand: generate a self-signed one on first run (or if the file + // went missing) and persist it, so SMTP STARTTLS and the HTTPS ingestion API work out of the box and the + // TLS certificate page reflects reality. Operators can replace it with their own under Settings. + try + { + var tlsPath = configCache.GetString(ConfigKeys.TlsCertPath, ""); + if (string.IsNullOrWhiteSpace(tlsPath) || !File.Exists(tlsPath)) + { + var cn = configCache.Listener().ServerName is { Length: > 0 } sn ? sn : "Dispatch"; + var (path, pw) = TlsCert.Generate(builder.Environment.ContentRootPath, cn); + await bootstrapRepo.SetAsync(ConfigKeys.TlsCertPath, path, encrypted: false); + await bootstrapRepo.SetAsync(ConfigKeys.TlsCertPassword, pw, encrypted: true); + await bootstrapRepo.SetAsync(ConfigKeys.TlsCertSource, "generated", encrypted: false); + await configCache.LoadAsync(bootstrapRepo); + Log.Information("Generated a self-signed TLS certificate for SMTP STARTTLS + HTTPS API; replace it under Settings → Connections → TLS certificate."); + } + } + catch (Exception ex) + { + Log.Warning(ex, "Could not provision the shared TLS certificate; STARTTLS/HTTPS API stay off until one is configured."); + } + + var listenerSnapshot = configCache.Listener(); + var apiSnapshot = configCache.Api(); + var webSnapshot = configCache.WebUi(); + var spoolSnapshot = configCache.Spool(); + // The Web UI TLS cert is the one setting that stays in appsettings (spec §12.1/§12.2). + builder.Configuration.GetSection(WebUiOptions.SectionName).Bind(webSnapshot); + + // Kestrel: dashboard/read API on the web port, ingestion API on the API port (from SQL config). + // The dashboard is HTTPS-only (spec §17.2): it uses the configured TLS cert (appsettings, §12.2) when + // present, otherwise an auto-generated, persisted self-signed cert - never plain HTTP. The ingestion + // API stays plain HTTP so devices/apps that can't do TLS can still post (it's gated by API keys). + // Optional HTTPS ingestion API (its own port) uses the shared TLS cert; if none is configured yet it + // falls back to the dashboard's self-signed cert, so enabling HTTPS never blocks startup. + System.Security.Cryptography.X509Certificates.X509Certificate2? apiTlsCert = null; + if (apiSnapshot.TlsEnabled) + { + if (!string.IsNullOrWhiteSpace(apiSnapshot.TlsCertPath) && File.Exists(apiSnapshot.TlsCertPath)) + apiTlsCert = System.Security.Cryptography.X509Certificates.X509CertificateLoader + .LoadPkcs12FromFile(apiSnapshot.TlsCertPath, apiSnapshot.TlsCertPassword); + else + { + Log.Warning("HTTPS API is enabled but no shared TLS certificate is configured; using a self-signed cert. Set one under Settings → Connections → TLS certificate."); + apiTlsCert = SelfSignedCert.GetOrCreate(builder.Environment.ContentRootPath); + } + } + + // Pin a modern TLS floor on every HTTPS listener (no TLS 1.0/1.1 fallback to the OS default). + const System.Security.Authentication.SslProtocols TlsFloor = + System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13; + builder.WebHost.ConfigureKestrel(k => + { + k.ListenAnyIP(webSnapshot.Port, lo => + { + if (!string.IsNullOrWhiteSpace(webSnapshot.TlsCertPath)) + lo.UseHttps(webSnapshot.TlsCertPath, + string.IsNullOrEmpty(webSnapshot.TlsCertPassword) ? null : webSnapshot.TlsCertPassword, + o => o.SslProtocols = TlsFloor); + else + lo.UseHttps(SelfSignedCert.GetOrCreate(builder.Environment.ContentRootPath), + o => o.SslProtocols = TlsFloor); + }); + if (apiSnapshot.HttpEnabled) + k.ListenAnyIP(apiSnapshot.Port); + if (apiTlsCert is not null) + k.ListenAnyIP(apiSnapshot.TlsPort, lo => lo.UseHttps(apiTlsCert, o => o.SslProtocols = TlsFloor)); + }); + + // ConfigCache is the runtime source of truth (spec §12.5). Section snapshots are exposed as IOptions for + // the startup-bound consumers (listener ports, spool dir/workers) that only apply at (re)start anyway; + // live consumers (size/CIDR/rate-limit filters) read ConfigCache directly. + builder.Services.AddSingleton(configCache); + builder.Services.AddSingleton(Options.Create(listenerSnapshot)); + builder.Services.AddSingleton(Options.Create(apiSnapshot)); + builder.Services.AddSingleton(Options.Create(webSnapshot)); + builder.Services.AddSingleton(Options.Create(spoolSnapshot)); + + // Retry/purge/default-relay defaults come from the typed Options classes; the SQL-backed settings + // providers (SqlRetrySettings/SqlPurgeSettings) read live values from the config table on top of them. + builder.Services.Configure(_ => { }); + builder.Services.Configure(_ => { }); + builder.Services.Configure(_ => { }); + + // Core singletons. A relative spool directory is resolved against the content root (not the process + // CWD, which is system32 for a Windows service / a read-only dir for the systemd unit), so the default + // "./.dispatch-spool" lands beside the app data, not wherever the service happened to start. + builder.Services.AddSingleton(sp => + { + var dir = sp.GetRequiredService>().Value.Directory; + if (!Path.IsPathRooted(dir)) + dir = Path.Combine(builder.Environment.ContentRootPath, dir); + return new SpoolDirectory(dir); + }); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + // SQL persistence (relay_log, relay_counters, relays, config, api_keys, message-log queries). + builder.Services.AddDispatchData(connectionString); + + // The dashboard listener is always HTTPS (configured or self-signed cert), so enforce Secure cookies + + // HSTS in any non-Development run. In Development the dashboard is reached via the Vite proxy over plain + // HTTP, so those are relaxed to keep dev login working. + var enforceHttps = !builder.Environment.IsDevelopment(); + + // Web/ingestion services (SignalR, live feed, rate limiter, API-key middleware) - must follow AddDispatchData. + builder.Services.AddDispatchWeb(enforceHttps, + configCache.GetInt(ConfigKeys.WebUiSessionTimeoutMinutes, 480)); + + // Hosted services: relay worker pool + SMTP listener. + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + // PurgeWorker is a singleton (so the manual /api/purge/run endpoint can invoke it) AND a hosted service. + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + var app = builder.Build(); + + // Global exception handler (OWASP A10): never leak internals - log with an error id, record a System + // audit event, and return a generic 500 carrying just that id. Must be first to catch downstream throws. + app.UseExceptionHandler(errApp => errApp.Run(async ctx => + { + var feature = ctx.Features.Get(); + var ex = feature?.Error; + var errorId = Guid.NewGuid().ToString("N")[..12]; + Log.Error(ex, "Unhandled exception {ErrorId} on {Path}", errorId, feature?.Path); + try + { + var audit = ctx.RequestServices.GetService(); + if (audit is not null) + await audit.System($"Unhandled error on {feature?.Path ?? ctx.Request.Path.Value}", + $"[{errorId}] {ex?.GetType().Name}: {ex?.Message}", ctx.Connection.RemoteIpAddress?.ToString()); + } + catch { /* audit is best-effort */ } + ctx.Response.StatusCode = Microsoft.AspNetCore.Http.StatusCodes.Status500InternalServerError; + ctx.Response.ContentType = "application/json"; + await ctx.Response.WriteAsync($"{{\"error\":\"An unexpected error occurred.\",\"id\":\"{errorId}\"}}"); + })); + + // Schema + default config were applied during bootstrap (before listeners were configured). The default + // relay is seeded "Unconfigured" by the schema migration; an administrator selects its provider in the UI. + var configRepo = app.Services.GetRequiredService(); + + // Seed the admin password from install config on first run (the installer supplies AdminPassword). + // If none is supplied, the web UI presents a one-time first-run setup screen instead. + var adminPassword = builder.Configuration["AdminPassword"]; + if (!string.IsNullOrWhiteSpace(adminPassword) + && string.IsNullOrEmpty(await configRepo.GetAsync(Dispatch.Web.Auth.AuthEndpoints.PasswordHashKey))) + { + // The seeded password bypasses the interactive UI policy; warn loudly if it's weak so a weak install + // seed doesn't silently become the admin credential (it's still seeded so install isn't blocked). + if (Dispatch.Web.Auth.AuthEndpoints.ValidatePassword(adminPassword) is { } weak) + Log.Warning("Seeded admin password is weak: {Reason} Change it from the dashboard or via 'reset-admin-password'.", weak); + await configRepo.SetAsync(Dispatch.Web.Auth.AuthEndpoints.PasswordHashKey, + BCrypt.Net.BCrypt.HashPassword(adminPassword, 12), encrypted: false); + Log.Information("Seeded admin password from install configuration"); + } + + // Once the password hash exists in SQL, the plaintext AdminPassword seed in appsettings is redundant. + // Wipe it so the admin password lives ONLY in the database (the file should hold just the connection + // string + TLS cert). Best-effort; covers both fresh seeds and installs that still carry an old seed. + if (!string.IsNullOrWhiteSpace(adminPassword) + && !string.IsNullOrEmpty(await configRepo.GetAsync(Dispatch.Web.Auth.AuthEndpoints.PasswordHashKey))) + { + foreach (var name in new[] { "appsettings.json", $"appsettings.{builder.Environment.EnvironmentName}.json" }) + { + var path = Path.Combine(builder.Environment.ContentRootPath, name); + try + { + if (!File.Exists(path)) continue; + if (System.Text.Json.Nodes.JsonNode.Parse(File.ReadAllText(path)) is not System.Text.Json.Nodes.JsonObject obj + || !obj.ContainsKey("AdminPassword")) continue; + obj.Remove("AdminPassword"); + File.WriteAllText(path, obj.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = true })); + Log.Information("Removed the consumed AdminPassword seed from {File}; the admin password now lives only in the database.", name); + } + catch (Exception ex) { Log.Warning(ex, "Could not strip the AdminPassword seed from {File}", name); } + } + } + + app.UseSecurityHeaders(webSnapshot.Port, enforceHttps); + app.UseEmbeddedUi(webSnapshot.Port); + app.UseAuthentication(); + app.UseMiddleware(); + app.UseMiddleware(); + app.MapIngestionApi(apiSnapshot); + app.MapDashboardApi(webSnapshot.Port); + app.MapPurgeOps(webSnapshot.Port); + app.MapHub("/hub/logs"); + app.MapHub("/hub/test-provider"); + app.MapEmbeddedUiFallback(webSnapshot.Port); + + // Record a startup lifecycle event in the audit log (best-effort). + try { await app.Services.GetRequiredService().Lifecycle("Dispatch service started"); } + catch (Exception ex) { Log.Warning(ex, "Could not write startup audit event"); } + + // Detect a version change since the last run so the dashboard can show a "you just upgraded" notice. + try { app.Services.GetRequiredService().DetectStartupUpgrade(); } + catch (Exception ex) { Log.Warning(ex, "Could not run startup upgrade detection"); } + + await app.RunAsync(); + return 0; +} +catch (Exception ex) +{ + Log.Fatal(ex, "Dispatch service terminated unexpectedly"); + return 1; +} +finally +{ + Log.CloseAndFlush(); +} + +// --- CLI: reset-admin-password ---------------------------------------------------------------- +// Minimal local recovery tool: prompts for a new dashboard password and writes its bcrypt hash to the +// SQL config table. Run on the server, e.g.: Dispatch.Service reset-admin-password +static async Task ResetAdminPasswordAsync(IConfiguration cfg) +{ + var cs = cfg.GetConnectionString("DispatchLog"); + if (string.IsNullOrWhiteSpace(cs)) + { + Console.Error.WriteLine("ConnectionStrings:DispatchLog is not configured (run from the install dir or set the env var)."); + return 1; + } + + Console.Write("New admin password: "); + var pw = ReadSecret(); + Console.Write("Confirm password: "); + var confirm = ReadSecret(); + if (pw != confirm) { Console.Error.WriteLine("Passwords do not match."); return 1; } + if (Dispatch.Web.Auth.AuthEndpoints.ValidatePassword(pw) is { } error) { Console.Error.WriteLine(error); return 1; } + + try + { + var repo = new SqlConfigRepository(new SqlConnectionFactory(cs)); + await repo.SetAsync(Dispatch.Web.Auth.AuthEndpoints.PasswordHashKey, + BCrypt.Net.BCrypt.HashPassword(pw, 12), encrypted: false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to update the password: {ex.Message}"); + return 1; + } + + Console.WriteLine("Admin password reset. Sign in to the dashboard with the new password."); + return 0; +} + +// Reads a line without echoing it (falls back to a normal read when input is piped). +static string ReadSecret() +{ + if (Console.IsInputRedirected) return Console.ReadLine() ?? ""; + var sb = new StringBuilder(); + while (true) + { + var k = Console.ReadKey(intercept: true); + if (k.Key == ConsoleKey.Enter) { Console.WriteLine(); break; } + if (k.Key == ConsoleKey.Backspace) { if (sb.Length > 0) sb.Length--; continue; } + if (!char.IsControl(k.KeyChar)) sb.Append(k.KeyChar); + } + return sb.ToString(); +} diff --git a/src/Dispatch.Service/Properties/launchSettings.json b/src/Dispatch.Service/Properties/launchSettings.json new file mode 100644 index 00000000..b19f0096 --- /dev/null +++ b/src/Dispatch.Service/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Dispatch.Service": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Dispatch.Service/SelfSignedCert.cs b/src/Dispatch.Service/SelfSignedCert.cs new file mode 100644 index 00000000..e10a01ec --- /dev/null +++ b/src/Dispatch.Service/SelfSignedCert.cs @@ -0,0 +1,60 @@ +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Serilog; + +namespace Dispatch.Service; + +/// +/// Supplies the dashboard's TLS certificate when the operator hasn't configured one (spec §17.2 - the admin +/// UI is HTTPS-only). A self-signed cert is generated once and persisted to the content root so it stays +/// stable across restarts (no new browser-trust prompt each time); it's regenerated only when missing, +/// unreadable, or within a day of expiry. +/// +public static class SelfSignedCert +{ + public static X509Certificate2 GetOrCreate(string dir) + { + var path = Path.Combine(dir, "dispatch-webui.pfx"); + + if (File.Exists(path)) + { + try + { + var existing = X509CertificateLoader.LoadPkcs12FromFile(path, null); + if (existing.NotAfter > DateTime.UtcNow.AddDays(1)) return existing; + } + catch { /* unreadable / expired - fall through and regenerate */ } + } + + // ECDSA P-256 (modern, smaller, faster) instead of RSA-2048; serverAuth only. + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var req = new CertificateRequest("CN=Dispatch SMTP Relay", ecdsa, HashAlgorithmName.SHA256); + req.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + req.CertificateExtensions.Add(new X509KeyUsageExtension( + X509KeyUsageFlags.DigitalSignature, critical: false)); // ECDHE server cert signs the handshake + req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, critical: false)); // serverAuth + var san = new SubjectAlternativeNameBuilder(); + san.AddDnsName("localhost"); + try { san.AddDnsName(Environment.MachineName); } catch { /* best-effort */ } + req.CertificateExtensions.Add(san.Build()); + + // 2-year validity (auto-regenerated within a day of expiry on startup) rather than 5 years. + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(2)); + var pfx = cert.Export(X509ContentType.Pfx); + try + { + File.WriteAllBytes(path, pfx); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); // 600 - contains the private key + } + catch (Exception ex) + { + Log.Warning(ex, "Could not persist the self-signed dashboard certificate to {Path} (a new one will be generated next start)", path); + } + + Log.Information("Dashboard TLS: using a self-signed certificate. Configure WebUi:TlsCertPath for a trusted certificate (spec §17.2)."); + return X509CertificateLoader.LoadPkcs12(pfx, null); + } +} diff --git a/src/Dispatch.Service/SmtpAuthThrottle.cs b/src/Dispatch.Service/SmtpAuthThrottle.cs new file mode 100644 index 00000000..e3c8dd04 --- /dev/null +++ b/src/Dispatch.Service/SmtpAuthThrottle.cs @@ -0,0 +1,70 @@ +using System.Collections.Concurrent; + +namespace Dispatch.Service; + +/// +/// Per-source-IP SMTP AUTH brute-force lockout (spec §17.10): 5 failed AUTH attempts within the window lock +/// that IP out for 60 seconds, during which AUTH is refused without touching the credential store. In-memory; +/// a successful AUTH clears the IP's failure history. Stale entries are pruned opportunistically so the map +/// can't grow unbounded. Mirrors the web dashboard's LoginThrottle with the SMTP policy. +/// +public sealed class SmtpAuthThrottle +{ + private const int MaxFailures = 5; + private static readonly TimeSpan Window = TimeSpan.FromMinutes(10); + private static readonly TimeSpan LockoutDuration = TimeSpan.FromSeconds(60); + + private readonly ConcurrentDictionary _byIp = new(); + private readonly Lock _lock = new(); + + private sealed class Entry + { + public readonly List Failures = []; + public DateTime? LockedUntilUtc; + } + + /// True if the IP is currently locked out (AUTH should be refused without checking credentials). + public bool IsLocked(string ip) + { + var now = DateTime.UtcNow; + lock (_lock) + { + if (!_byIp.TryGetValue(ip, out var e) || e.LockedUntilUtc is not { } until) return false; + if (until <= now) { _byIp.TryRemove(ip, out _); return false; } + return true; + } + } + + public void RecordFailure(string ip) + { + var now = DateTime.UtcNow; + lock (_lock) + { + var e = _byIp.GetOrAdd(ip, _ => new Entry()); + e.Failures.RemoveAll(t => now - t > Window); + e.Failures.Add(now); + if (e.Failures.Count >= MaxFailures) + { + e.LockedUntilUtc = now + LockoutDuration; + e.Failures.Clear(); + } + PruneStale(now); + } + } + + public void RecordSuccess(string ip) + { + lock (_lock) { _byIp.TryRemove(ip, out _); } + } + + private void PruneStale(DateTime now) + { + // Called under _lock. Drop entries with no recent failures and no active lockout. + foreach (var (ip, e) in _byIp) + { + var lockedActive = e.LockedUntilUtc is { } u && u > now; + var hasRecent = e.Failures.Count > 0 && now - e.Failures[^1] <= Window; + if (!lockedActive && !hasRecent) _byIp.TryRemove(ip, out _); + } + } +} diff --git a/src/Dispatch.Service/SmtpListenerService.cs b/src/Dispatch.Service/SmtpListenerService.cs new file mode 100644 index 00000000..5b19072c --- /dev/null +++ b/src/Dispatch.Service/SmtpListenerService.cs @@ -0,0 +1,197 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using Dispatch.Core.Configuration; +using Dispatch.Core.Maintenance; +using Dispatch.Core.Spool; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SmtpServer; + +namespace Dispatch.Service; + +/// +/// Hosts the for the process lifetime (spec §5, §19.2). Wires the +/// spool message store, the CIDR/size mailbox filter, and (optionally) SMTP AUTH against the configured +/// credential allow-list and STARTTLS from a PFX certificate. +/// +public sealed class SmtpListenerService : BackgroundService +{ + private readonly SpoolMessageStore _messageStore; + private readonly CidrMailboxFilter _mailboxFilter; + private readonly ConfiguredUserAuthenticator _authenticator; + private readonly ConnectionTracker _connections; + private readonly ListenerOptions _options; + private readonly SmtpListenerState _state; + private readonly IHostEnvironment _env; + private readonly ILogger _log; + + public SmtpListenerService( + SpoolMessageStore messageStore, + CidrMailboxFilter mailboxFilter, + ConfiguredUserAuthenticator authenticator, + ConnectionTracker connections, + IOptions options, + SmtpListenerState state, + IHostEnvironment env, + ILogger log) + { + _messageStore = messageStore; + _mailboxFilter = mailboxFilter; + _authenticator = authenticator; + _connections = connections; + _options = options.Value; + _state = state; + _env = env; + _log = log; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var ports = ResolveBindablePorts(_options.EffectivePorts); + if (ports.Length == 0) + { + // Nothing bindable (already logged). Return without starting the SMTP server so the rest of the + // host (dashboard + ingestion API) keeps running - a missing SMTP port must never take it down. + _state.ListeningPorts = []; + return; + } + // Publish the resolved set so /health and the dashboard can show what's actually listening (which may + // differ from the configured ports when 25 fell back to 2525). + _state.ListeningPorts = ports; + var certificate = LoadCertificate(); + + var optionsBuilder = new SmtpServerOptionsBuilder().ServerName(_options.ServerName); + foreach (var port in ports) + { + optionsBuilder.Endpoint(e => + { + e.Port(port, isSecure: false); + // Secure default: AUTH is only offered after STARTTLS so credentials never cross the wire in + // the clear. Operators can re-enable plaintext AUTH (internal/dev) via listener.allow_unsecure_auth. + e.AllowUnsecureAuthentication(_options.AllowUnsecureAuth); + if (_options.RequireAuth) e.AuthenticationRequired(); + if (certificate is not null) + { + e.Certificate(certificate); + // Pin a modern TLS floor for STARTTLS (no TLS 1.0/1.1 fallback to the OS default). + e.SupportedSslProtocols( + System.Security.Authentication.SslProtocols.Tls12 | System.Security.Authentication.SslProtocols.Tls13); + } + }); + } + if (_options.MaxMessageBytes is > 0 and <= int.MaxValue) + optionsBuilder.MaxMessageSize((int)_options.MaxMessageBytes); + if (_options.ConnectionTimeoutSeconds > 0) + optionsBuilder.CommandWaitTimeout(TimeSpan.FromSeconds(_options.ConnectionTimeoutSeconds)); + + var serviceProvider = new SmtpServer.ComponentModel.ServiceProvider(); + serviceProvider.Add(_messageStore); + serviceProvider.Add(_mailboxFilter); + serviceProvider.Add(_authenticator); + + var server = new SmtpServer.SmtpServer(optionsBuilder.Build(), serviceProvider); + + // Track live connections for the max-connections cap (spec §5.3). The cap is enforced at MAIL FROM + // by CidrMailboxFilter (the library accepts the TCP connection before these events fire). + server.SessionCreated += (_, _) => _connections.Increment(); + server.SessionCompleted += (_, _) => _connections.Decrement(); + server.SessionFaulted += (_, _) => _connections.Decrement(); + server.SessionCancelled += (_, _) => _connections.Decrement(); + + _log.LogInformation("SMTP listener starting on port(s) {Ports}{Auth}{Tls} (timeout {Timeout}s, max {Max} conns)", + string.Join(", ", ports), + _options.RequireAuth ? " (AUTH required)" : "", + certificate is not null ? " (STARTTLS)" : "", + _options.ConnectionTimeoutSeconds, _options.MaxConnections); + + try + { + await server.StartAsync(stoppingToken); + } + catch (OperationCanceledException) + { + // normal shutdown + } + catch (Exception ex) + { + // A bind failure here (e.g. a port was taken in the race between our probe and the actual bind) + // must NOT crash the host - by default an unhandled exception in a BackgroundService stops the + // whole app. Log and return so the dashboard + ingestion API stay up. + _state.ListeningPorts = []; + _log.LogError(ex, "SMTP listener failed to start - the dashboard and ingestion API are unaffected"); + return; + } + + _state.ListeningPorts = []; + _log.LogInformation("SMTP listener stopped"); + } + + /// + /// Resolve the ports we can actually bind. We prefer the configured ports (25 + 587 by default), but + /// binding the privileged ports needs root / CAP_NET_BIND_SERVICE, and a port may already be taken by + /// another MTA. Each port is probed; unbindable ones are dropped with a warning. If port 25 was requested + /// but can't be bound (in use or no privilege), we fall back to the unprivileged 2525 so mail still flows. + /// + private int[] ResolveBindablePorts(int[] requested) + { + // The decision logic lives in SmtpPortResolver (pure + unit-tested); here we supply the real socket + // probe and route its warnings to the log. + var result = SmtpPortResolver.Resolve(requested, CanBind, msg => _log.LogWarning("{Message}", msg)); + + if (result.Length == 0) + _log.LogError("No SMTP port could be bound from [{Requested}] - the listener will not accept mail", + string.Join(", ", requested)); + + return result; + } + + /// Probe whether a TCP port can be bound. EACCES (privilege) and EADDRINUSE (in use) both surface + /// as , which is exactly what we treat as "unavailable". + private static bool CanBind(int port) + { + try + { + var probe = new TcpListener(IPAddress.Any, port); + probe.Start(); + probe.Stop(); + return true; + } + catch (SocketException) + { + return false; + } + } + + private X509Certificate2? LoadCertificate() + { + // Prefer the operator's shared TLS certificate (also secures the HTTPS API) when configured. + if (!string.IsNullOrWhiteSpace(_options.TlsCertPath)) + { + try + { + return X509CertificateLoader.LoadPkcs12FromFile(_options.TlsCertPath, _options.TlsCertPassword); + } + catch (Exception ex) + { + _log.LogError(ex, + "Failed to load the shared TLS certificate from {Path}; falling back to a self-signed cert for STARTTLS", + _options.TlsCertPath); + } + } + + // No shared cert configured (or it failed to load): use the same auto-generated, persisted self-signed + // cert the dashboard and HTTPS API use, so STARTTLS is available out of the box. Operators can replace + // it with a CA-trusted shared cert in the dashboard (Settings -> TLS certificate). + try + { + return SelfSignedCert.GetOrCreate(_env.ContentRootPath); + } + catch (Exception ex) + { + _log.LogError(ex, "Could not obtain a self-signed certificate; STARTTLS disabled"); + return null; + } + } +} diff --git a/src/Dispatch.Service/appsettings.json b/src/Dispatch.Service/appsettings.json new file mode 100644 index 00000000..0ce400bd --- /dev/null +++ b/src/Dispatch.Service/appsettings.json @@ -0,0 +1,16 @@ +{ + "_comment": "Spec §12.1: appsettings holds ONLY the DB connection string and the Web UI TLS cert. All other settings live in the SQL config table and are managed from the dashboard. The installer writes ConnectionStrings:DispatchLog; dev uses appsettings.Development.json.", + "ConnectionStrings": { + "DispatchLog": "" + }, + "WebUi": { + "TlsCertPath": "", + "TlsCertPassword": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Dispatch.UI/index.html b/src/Dispatch.UI/index.html new file mode 100644 index 00000000..7355c678 --- /dev/null +++ b/src/Dispatch.UI/index.html @@ -0,0 +1,12 @@ + + + + + + Dispatch + + +
+ + + diff --git a/src/Dispatch.UI/package-lock.json b/src/Dispatch.UI/package-lock.json new file mode 100644 index 00000000..2dd7f685 --- /dev/null +++ b/src/Dispatch.UI/package-lock.json @@ -0,0 +1,2529 @@ +{ + "name": "dispatch-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dispatch-ui", + "version": "0.1.0", + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "@tanstack/react-query": "^5.59.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.1.1", + "recharts": "^2.13.3" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/signalr": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.17.tgz", + "integrity": "sha512-5pM6xPtKZNJLO0Tq5nQasVyPFwi/WBY3QB5uc/v3dIPTpS1JXQbaXAQAPxFoQ5rTBFE094w8bbqkp17F9ReQvA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.372", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", + "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "license": "MIT", + "dependencies": { + "react-router": "7.17.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/Dispatch.UI/package.json b/src/Dispatch.UI/package.json new file mode 100644 index 00000000..2524bd30 --- /dev/null +++ b/src/Dispatch.UI/package.json @@ -0,0 +1,26 @@ +{ + "name": "dispatch-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "@tanstack/react-query": "^5.59.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.1.1", + "recharts": "^2.13.3" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.5" + } +} diff --git a/src/Dispatch.UI/src/ActionsMenu.tsx b/src/Dispatch.UI/src/ActionsMenu.tsx new file mode 100644 index 00000000..36b09d8b --- /dev/null +++ b/src/Dispatch.UI/src/ActionsMenu.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from "react"; + +export interface MenuAction { label: string; onClick: () => void; danger?: boolean; disabled?: boolean } + +/// A "⋯" button that opens a small dropdown of row actions. Closes on outside-click or Esc. +export function ActionsMenu({ items }: { items: MenuAction[] }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onKey); }; + }, [open]); + + return ( +
+ + {open && ( +
+ {items.map((it, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/Dispatch.UI/src/FirstRunWizard.tsx b/src/Dispatch.UI/src/FirstRunWizard.tsx new file mode 100644 index 00000000..c158a799 --- /dev/null +++ b/src/Dispatch.UI/src/FirstRunWizard.tsx @@ -0,0 +1,360 @@ +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; +import { api, type TestResult } from "./lib/api"; +import { PROVIDER_FIELDS, PROVIDER_LABELS, PROVIDER_ORDER } from "./lib/providers"; +import { ProviderFieldsInput } from "./ProviderFields"; +import { TestResultView } from "./TestResultView"; +import { validCidr } from "./lib/cidr"; + +type Step = "welcome" | "provider" | "test" | "routing" | "access" | "done"; + +export function FirstRunWizard({ onDone }: { onDone: () => void }) { + const [step, setStep] = useState("welcome"); + const [catchAll, setCatchAll] = useState<{ id: number; provider: string } | null>(null); + + const steps: { id: Step; label: string }[] = [ + { id: "welcome", label: "Welcome" }, + { id: "provider", label: "Provider" }, + { id: "test", label: "Test" }, + { id: "routing", label: "Routing" }, + { id: "access", label: "Access" }, + ]; + const activeIdx = steps.findIndex((s) => s.id === step); + + return ( +
+
+
+ + Dispatch setup + +
+ + {step !== "done" && ( +
+ {steps.map((s, i) => ( +
+
+
{s.label}
+
+ ))} +
+ )} + + {step === "welcome" && setStep("provider")} />} + {step === "provider" && ( + setStep("welcome")} + onDone={(id, provider) => { setCatchAll({ id, provider }); setStep(provider === "Local" ? "routing" : "test"); }} + /> + )} + {step === "test" && catchAll && ( + setStep("provider")} onNext={() => setStep("routing")} /> + )} + {step === "routing" && catchAll && ( + setStep("test")} onNext={() => setStep("access")} /> + )} + {step === "access" && setStep("routing")} onNext={() => setStep("done")} />} + {step === "done" && } +
+
+ ); +} + +// ---- Steps ----------------------------------------------------------------------------------- + +function Welcome({ onNext }: { onNext: () => void }) { + return ( + <> +

Welcome 👋

+

+ Dispatch is your own SMTP relay: point your apps and devices at it, and it forwards every message + to an email provider - with a durable queue and this dashboard to watch it all. +

+

Let's connect your first provider. It takes about a minute.

+