diff --git a/.env.staging.example b/.env.staging.example
new file mode 100644
index 0000000..c893f60
--- /dev/null
+++ b/.env.staging.example
@@ -0,0 +1,17 @@
+# Copy to deploy/.env.staging and fill in. Used by:
+# docker compose -p inboxintel-staging --env-file deploy/.env.staging \
+# -f docker-compose.yml -f docker-compose.staging.yml up
+# (or ./deploy/up.ps1 -Staging). Kept separate from deploy/.env (production) so
+# staging can never touch prod credentials, DB, or Google project.
+POSTGRES_PASSWORD=change-me-staging
+
+# Use a SEPARATE Google OAuth client for staging with redirect URI:
+# http://localhost:18081/signin-google
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+
+AI_MODE=Disabled
+FRONTEND_ORIGIN=http://localhost:18081
+
+# Staging caps the initial mailbox sync so rehearsals are fast.
+MAX_MESSAGES=2000
diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml
new file mode 100644
index 0000000..669ca89
--- /dev/null
+++ b/.gitea/workflows/deploy-prod.yml
@@ -0,0 +1,45 @@
+name: Deploy Production
+
+# Production promotion. The APPROVAL GATE is the git tag: production only ever
+# deploys a tagged release cut on main (see docs/WORKFLOW.md §5). Cutting the tag
+# is the deliberate, auditable "approve to go live" action — and the tag doubles
+# as the rollback target. workflow_dispatch adds a manual "Run workflow" button
+# for re-deploys/rollbacks.
+#
+# STATUS: inactive until (a) the Linux production server exists and (b) a
+# self-hosted Gitea runner is registered on it with labels [self-hosted, production].
+# Tag pushes before then will queue harmlessly. This file is the wiring, ready to
+# switch on — review the deploy step for your server before first use.
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: 'Tag or commit to deploy (e.g. v1.2.0)'
+ required: true
+
+jobs:
+ deploy:
+ runs-on: [self-hosted, production]
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Deploy the exact tag that triggered the run (immutable), or the
+ # ref given to a manual dispatch.
+ ref: ${{ github.event.inputs.ref || github.ref_name }}
+ fetch-depth: 0
+
+ - name: Deploy release to production
+ run: |
+ echo "Deploying ${{ github.event.inputs.ref || github.ref_name }} to production"
+ # deploy/.env lives on the server (never in git). up.sh validates it,
+ # builds the Linux images, applies EF migrations on boot, and starts
+ # the stack behind the nginx reverse proxy.
+ ./deploy/up.sh --proxy
+
+ - name: Smoke check
+ run: |
+ sleep 5
+ curl -fsS http://localhost/ >/dev/null && echo "Prod responding on :80" || \
+ { echo "Smoke check failed"; exit 1; }
diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml
new file mode 100644
index 0000000..d38cf36
--- /dev/null
+++ b/.gitea/workflows/deploy-staging.yml
@@ -0,0 +1,42 @@
+name: Deploy Staging
+
+# Continuous deployment to the LOCAL staging stack. Fires when develop advances
+# (i.e. after a PR is merged into develop). It re-verifies the code, then rebuilds
+# and restarts the isolated staging stack on this machine.
+#
+# CRITICAL: staging lives on your Windows box (ports 18080/18081). A cloud or
+# container runner CANNOT reach it, so this job MUST run on a self-hosted Gitea
+# Actions runner registered ON that Windows machine with Docker access
+# (labels: self-hosted, windows). Until that runner exists this job just waits
+# in the queue (harmless, cancelable) — deploy staging manually meanwhile with:
+# ./deploy/up.ps1 -Staging
+on:
+ push:
+ branches: [develop]
+ workflow_dispatch: {} # also allow a manual "Run workflow" from the Gitea UI
+
+jobs:
+ deploy:
+ runs-on: [self-hosted, windows]
+ steps:
+ - uses: actions/checkout@v4
+
+ # Re-run the gate on the exact merged code before it touches staging.
+ - name: Build + test (Release)
+ shell: powershell
+ run: |
+ dotnet build InboxIntel.sln -c Release --nologo
+ dotnet test InboxIntel.sln -c Release --no-build --nologo
+
+ - name: Redeploy staging stack
+ shell: powershell
+ run: |
+ docker compose -p inboxintel-staging `
+ --env-file deploy/.env.staging `
+ -f docker-compose.yml -f docker-compose.staging.yml `
+ up -d --build
+ docker compose -p inboxintel-staging ps
+
+ - name: Staging endpoints
+ shell: powershell
+ run: Write-Host "Staging up — Frontend http://localhost:18081 API http://localhost:18080/swagger"
diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml
new file mode 100644
index 0000000..fce1e4f
--- /dev/null
+++ b/.gitea/workflows/security.yml
@@ -0,0 +1,54 @@
+name: Security
+
+# Scans run alongside CI on every PR and on pushes to the long-lived branches.
+# This is the DETECTIVE layer (backstop). The PREVENTIVE layer is the local
+# pre-commit hook — this catches anything that slipped past it (e.g. --no-verify)
+# and scans the full history, not just the staged diff.
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+
+jobs:
+ secrets:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # full history so gitleaks scans every commit
+ - name: Secret scan (gitleaks)
+ # Run the binary directly — the container-mode runner has no Docker socket,
+ # so `docker run` isn't available inside a job.
+ run: |
+ GITLEAKS_VERSION=8.18.4
+ curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" -o /tmp/gitleaks.tar.gz
+ tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
+ /tmp/gitleaks detect --source=. --redact --verbose --exit-code=1
+
+ dependencies:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+ - name: Restore
+ run: dotnet restore InboxIntel.sln
+ - name: .NET vulnerable packages (fail on any)
+ # Match dotnet's own "has the following vulnerable packages" line rather
+ # than raw severity words, so package/project names can't false-positive.
+ run: |
+ dotnet list InboxIntel.sln package --vulnerable --include-transitive 2>&1 | tee vuln.txt
+ if grep -q "has the following vulnerable" vuln.txt; then
+ echo "::error::Vulnerable NuGet packages detected — see the table above."
+ exit 1
+ fi
+ echo "No vulnerable NuGet packages."
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ - name: npm audit (production deps, fail on high/critical)
+ # Only production dependencies ship to users; dev-only toolchain advisories
+ # (Vite/PostCSS/etc.) shouldn't block a merge.
+ working-directory: frontend
+ run: npm audit --omit=dev --audit-level=high
diff --git a/.gitignore b/.gitignore
index ec071ac..43dea55 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,7 @@ lpt[1-9].*
*.code-workspace
.env.*
!.env.example
+!.env.staging.example
.next/
dist/
build/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..55eacfa
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,35 @@
+# Changelog
+
+All notable changes to InboxIntel are documented here. Format follows
+[Keep a Changelog](https://keepachangelog.com/); versions follow
+[Semantic Versioning](https://semver.org/). See [docs/WORKFLOW.md](docs/WORKFLOW.md).
+
+## [Unreleased]
+### Security
+- Patched all High/Moderate NuGet advisories the new dependency gate surfaced:
+ `System.Text.Json` 8.0.0→8.0.6, `Microsoft.Extensions.Caching.Memory` 8.0.0→8.0.1
+ (+ `DependencyInjection.Abstractions`→8.0.2), `System.Security.Cryptography.Xml`
+ 8.0.1→8.0.3 (transitive pins), and `MailKit`/`MimeKit` 4.13.0→4.17.0.
+
+### Added
+- CI/CD pipeline (`.gitea/workflows/`): `security` (gitleaks secret scan + NuGet/npm
+ vulnerability gate), `deploy-staging` (auto-redeploy local staging on `develop`),
+ `deploy-prod` (tag-gated production promotion, inactive until the server exists).
+- Formal Git workflow & environment strategy (`docs/WORKFLOW.md`).
+- Staging environment overlay (`docker-compose.staging.yml`) — production-shaped
+ Linux containers on Windows, isolated ports/volumes.
+- Version-controlled Git hooks (`scripts/git-hooks/`) + installer
+ (`scripts/install-hooks.ps1`): pre-commit secret/format checks, pre-push
+ build+test gate.
+- `VERSION` file as the single source of truth for the release number.
+
+## [0.1.0] — scaffold
+### Added
+- .NET 8 Clean Architecture backend (Domain/Application/Infrastructure/Api) + React/Vite SPA.
+- Docker Compose stack (Postgres 16, API, frontend, optional nginx proxy).
+- Gitea Actions CI (backend build+test, frontend build) on `main`/`develop` + PRs.
+- Security hardening: encrypted OAuth tokens, EF global query filters (IDOR),
+ loopback binds, non-root containers, SSRF egress guard.
+- One-command deploy scripts (`deploy/up.ps1`, `deploy/up.sh`).
+
+[Unreleased]: https://your-gitea-host/InboxIntel/compare/v0.1.0...HEAD
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..6e8bf73
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.1.0
diff --git a/deploy/down.ps1 b/deploy/down.ps1
index deb704e..403c073 100644
--- a/deploy/down.ps1
+++ b/deploy/down.ps1
@@ -5,13 +5,22 @@
./deploy/down.ps1
./deploy/down.ps1 -Volumes
#>
-param([switch]$Volumes)
+param([switch]$Volumes, [switch]$Staging)
$ErrorActionPreference = 'Stop'
-$root = Split-Path -Parent $PSScriptRoot
-$envFile = Join-Path $PSScriptRoot '.env'
+$root = Split-Path -Parent $PSScriptRoot
-$composeArgs = @('compose', '--env-file', $envFile, 'down')
+if ($Staging) {
+ $envFile = Join-Path $PSScriptRoot '.env.staging'
+ $composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
+ $project = @('-p', 'inboxintel-staging')
+} else {
+ $envFile = Join-Path $PSScriptRoot '.env'
+ $composeFiles = @()
+ $project = @()
+}
+
+$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles + @('down')
if ($Volumes) { $composeArgs += '--volumes' }
Push-Location $root
diff --git a/deploy/up.ps1 b/deploy/up.ps1
index a6dfd5b..46f55b8 100644
--- a/deploy/up.ps1
+++ b/deploy/up.ps1
@@ -8,15 +8,28 @@
#>
param(
[switch]$Proxy,
- [switch]$Foreground
+ [switch]$Foreground,
+ [switch]$Staging # production-shaped staging stack: separate env, ports, volumes
)
$ErrorActionPreference = 'Stop'
-$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
-$envFile = Join-Path $PSScriptRoot '.env'
+$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
-if (-not (Test-Path $envFile)) {
- throw "Missing $envFile. Create it from .env.example with your real secrets."
+# Staging vs production: pick the env file + compose overlay + isolated project name.
+if ($Staging) {
+ $envFile = Join-Path $PSScriptRoot '.env.staging'
+ $composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
+ $project = @('-p', 'inboxintel-staging')
+ if (-not (Test-Path $envFile)) {
+ throw "Missing $envFile. Create it from .env.staging.example."
+ }
+} else {
+ $envFile = Join-Path $PSScriptRoot '.env'
+ $composeFiles = @()
+ $project = @()
+ if (-not (Test-Path $envFile)) {
+ throw "Missing $envFile. Create it from .env.example with your real secrets."
+ }
}
# Fail fast if a required key is absent or blank.
@@ -28,19 +41,23 @@ Get-Content $envFile | ForEach-Object {
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) }
if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" }
-$composeArgs = @('compose', '--env-file', $envFile)
+$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles
if ($Proxy) { $composeArgs += @('--profile', 'proxy') }
$composeArgs += @('up', '--build')
if (-not $Foreground) { $composeArgs += '-d' }
Push-Location $root
try {
- Write-Host "Starting InboxIntel via docker compose (env: deploy/.env)..." -ForegroundColor Cyan
+ Write-Host "Starting InboxIntel via docker compose (env: $envFile)..." -ForegroundColor Cyan
& docker @composeArgs
if (-not $Foreground) {
- & docker compose --env-file $envFile ps
- Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
- Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1" -ForegroundColor DarkGray
+ & docker compose @project --env-file $envFile @composeFiles ps
+ if ($Staging) {
+ Write-Host "`n[STAGING] Frontend: http://localhost:18081 API/Swagger: http://localhost:18080/swagger" -ForegroundColor Green
+ } else {
+ Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
+ }
+ Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1$(if($Staging){' -Staging'})" -ForegroundColor DarkGray
}
}
finally { Pop-Location }
diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml
new file mode 100644
index 0000000..be62449
--- /dev/null
+++ b/docker-compose.staging.yml
@@ -0,0 +1,44 @@
+# Staging overlay for InboxIntel.
+#
+# The base docker-compose.yml IS the production definition (Linux containers,
+# ASPNETCORE_ENVIRONMENT=Production). This overlay layers a *staging* variant on
+# top of it so you can run a production-shaped stack locally on Windows WITHOUT
+# clobbering a real production deployment's data, ports, or volumes.
+#
+# It differs from prod only in the ways staging is meant to differ:
+# - the dev/test banner is on (App__DevMode=true)
+# - the initial Gmail sync is capped so a big mailbox doesn't take forever
+# - ports are shifted into the 18xxx range so staging can run alongside prod
+# - a distinct project name gives it its own isolated pgdata + keys volumes
+#
+# Run it with the -p (project name) flag so volumes/networks are namespaced:
+#
+# docker compose -p inboxintel-staging \
+# --env-file deploy/.env.staging \
+# -f docker-compose.yml -f docker-compose.staging.yml up --build -d
+#
+# (deploy/up.ps1 -Staging / up.sh --staging wrap this for you.)
+
+services:
+ postgres:
+ ports:
+ - "127.0.0.1:15432:5432"
+
+ api:
+ environment:
+ # Same Production runtime as prod (real config binding, real build), but
+ # flagged as a non-production instance so the UI shows the staging banner
+ # and the first sync is bounded.
+ App__DevMode: "true"
+ GmailSync__MaxMessages: ${MAX_MESSAGES:-2000}
+ Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:18081}
+ ports:
+ - "127.0.0.1:18080:8080"
+
+ frontend:
+ ports:
+ - "18081:80"
+
+ nginx:
+ ports:
+ - "18000:80"
diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md
new file mode 100644
index 0000000..de116fc
--- /dev/null
+++ b/docs/WORKFLOW.md
@@ -0,0 +1,188 @@
+# InboxIntel — Git Workflow & Environment Strategy
+
+The single source of truth for how we branch, commit, version, and promote code
+across environments. Optimised for a **solo developer on Windows 11 with a future
+Linux production server**. Kept deliberately lightweight — every rule here earns
+its place.
+
+---
+
+## 1. Branch strategy
+
+A trimmed **GitHub Flow + a long-lived `develop`** model. Two permanent branches,
+short-lived branches off `develop`.
+
+| Branch | Lives for | Purpose | Deploys to |
+|-----------------|-----------|------------------------------------------------------|------------|
+| `main` | forever | Always releasable. Every commit is tagged & shippable | production |
+| `develop` | forever | Integration branch. What staging runs | staging |
+| `feature/*` | hours–days| One feature or refactor | dev (local)|
+| `fix/*` | hours | Non-urgent bug fix | dev (local)|
+| `hotfix/*` | minutes–hrs| Urgent prod fix, branched from `main` | prod (fast)|
+| `release/x.y.0` | optional | Only if a release needs stabilisation before tagging | staging |
+
+**Why this shape (not full GitFlow):** a solo dev doesn't need GitFlow's ceremony
+(separate release managers, parallel release trains). But keeping `develop`
+separate from `main` gives one thing that pure trunk-based can't: a **staging
+environment that always mirrors `develop`** while `main` stays clean and
+tag-perfect for production. `release/*` exists only when you want to freeze
+features and stabilise — skip it for routine work.
+
+### Normal flow
+
+```
+main ──────●────────────────────●────────── (tagged: v1.2.0, v1.3.0)
+ \ /
+develop ──●──●──●──●──●──●──●──●──────────── (staging)
+ \ / \ /
+feature/x ●─● \ /
+fix/y ●───●
+```
+
+1. `git switch develop && git pull`
+2. `git switch -c feature/sender-policy`
+3. Commit in small conventional commits.
+4. Push, open a PR **into `develop`** (Gitea). CI must be green.
+5. Squash-merge. Delete the branch.
+6. When `develop` is ready to ship → PR `develop → main`, tag, deploy.
+
+---
+
+## 2. Commit conventions — Conventional Commits
+
+Format: `type(scope): short imperative summary`
+
+**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `perf`,
+`build`, `style`, `security`.
+
+Rules:
+- Summary ≤ 72 chars, imperative mood ("add", not "added").
+- One logical change per commit.
+- Body explains **why**, not what (the diff shows what).
+- Breaking change → add `!` (`feat!:`) and a `BREAKING CHANGE:` footer.
+
+Examples (matching this repo's history):
+```
+feat(ui): F1+F2 — component primitives + rebuilt app shell
+fix(security): systemic IDOR safeguard via EF global query filters
+ci: add Gitea Actions build + test pipeline
+```
+
+Why: conventional commits drive **automatic semver bumps** and a generated
+CHANGELOG, and make `git log` scannable. `feat` → minor, `fix` → patch,
+`BREAKING CHANGE` → major.
+
+---
+
+## 3. Pull request / merge flow (Gitea)
+
+Even solo, PRs are worth it: they run CI, give a diff review checkpoint, and build
+a paper trail.
+
+- **Target:** `feature/*` and `fix/*` → `develop`. `develop`/`hotfix/*` → `main`.
+- **Gate:** the `CI` workflow (backend build+test, frontend build) must pass.
+- **Merge style:** **squash-merge** feature branches (one clean commit on
+ `develop`). **Merge commit** for `develop → main` (preserves the integration
+ history and makes the release boundary visible).
+- **Branch protection (Gitea → Settings → Branches):** protect `main` and
+ `develop`; require status checks to pass; disallow force-push.
+
+---
+
+## 4. Versioning — Semantic Versioning (`MAJOR.MINOR.PATCH`)
+
+- **MAJOR** — breaking API/behaviour change.
+- **MINOR** — backward-compatible feature.
+- **PATCH** — backward-compatible fix.
+
+The current version lives in [`VERSION`](../VERSION) and is the one place bumped
+per release. Pre-1.0 while scaffolding: stay on `0.x` (minor = features, patch =
+fixes; anything may change).
+
+---
+
+## 5. Tagging & releases
+
+Tags are cut **only on `main`**, annotated, prefixed `v`:
+
+```bash
+git switch main && git pull
+# bump VERSION + CHANGELOG in a release commit, then:
+git tag -a v1.3.0 -m "v1.3.0 — sender policy + staging overlay"
+git push origin main --tags
+```
+
+- Tag == the exact commit deployed to production == the rollback target.
+- The tag message summarises the release; details live in `CHANGELOG.md`.
+
+---
+
+## 6. Hotfix process
+
+For a bug already in production:
+
+```bash
+git switch main && git pull
+git switch -c hotfix/oauth-callback-500
+# fix + test
+git commit -m "fix(security): guard null OAuth state on callback"
+# PR hotfix/* -> main, CI green, merge
+git switch main && git pull
+git tag -a v1.3.1 -m "v1.3.1 hotfix — OAuth callback" && git push origin main --tags
+# deploy the tag, THEN back-merge so develop doesn't lose the fix:
+git switch develop && git merge main && git push
+```
+
+The **back-merge to `develop`** is the step people forget — without it the next
+release silently reintroduces the bug.
+
+---
+
+## 7. Environments
+
+| Env | Host | Runtime | Config source | Purpose |
+|-------------|-------------------------|--------------------------------------|--------------------------|--------------------|
+| Development | Windows 11 (native) | `dotnet run` + `vite` (hot reload) | `appsettings.Development.json` + user-secrets | fast iteration, debugging |
+| Staging | Windows 11 (Docker) | Linux containers, `ASPNETCORE_ENVIRONMENT=Production` | `deploy/.env.staging` + `docker-compose.staging.yml` | production rehearsal |
+| Production | Linux server (Docker) | identical Linux containers | `deploy/.env` on the server | live |
+
+**Parity principle:** dev is fast (native, Windows) and *not* production-shaped —
+that's fine, it's for inner-loop speed. **Staging is the parity gate**: it runs the
+*same Linux images* Docker builds for production, so "works in staging" genuinely
+predicts "works in prod". The only staging↔prod differences are the dev banner,
+capped sync, ports, and volume namespace — see `docker-compose.staging.yml`.
+
+### Commands
+
+```powershell
+# DEV (native, hot reload) — two terminals
+dotnet run --project src/InboxIntel.Api # http://localhost:5080
+cd frontend; npm run dev # http://localhost:5173
+
+# STAGING (production-shaped, on Windows via Docker)
+./deploy/up.ps1 -Staging # http://localhost:18081
+./deploy/down.ps1 -Staging
+
+# PRODUCTION-shaped run locally (smoke test the real config)
+./deploy/up.ps1 # http://localhost:8081
+```
+
+Dev and staging use **different ports and different volumes**, so they can run at
+the same time and never share a database.
+
+---
+
+## 8. Git hooks
+
+Installed via `core.hooksPath` (run once per clone):
+
+```powershell
+./scripts/install-hooks.ps1
+```
+
+- **pre-commit** (fast): blocks committed `.env`/secrets, verifies `dotnet format`.
+- **pre-push** (thorough): `dotnet build -c Release` + `dotnet test` + frontend
+ build — the same gate CI runs, caught before the push.
+
+Emergency bypass: `--no-verify`. CI still enforces the gate server-side, so a
+bypassed push can still be rejected by branch protection.
diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit
new file mode 100755
index 0000000..4e8ca9e
--- /dev/null
+++ b/scripts/git-hooks/pre-commit
@@ -0,0 +1,43 @@
+#!/usr/bin/env sh
+# InboxIntel pre-commit hook — fast checks only (keep it under a few seconds).
+# Heavier build/test verification runs in pre-push. Bypass in an emergency with:
+# git commit --no-verify
+set -eu
+
+echo "[pre-commit] running fast checks..."
+
+# 1) Block obvious secrets from being committed. Scans only staged, added lines.
+STAGED=$(git diff --cached --name-only --diff-filter=ACM)
+if [ -n "$STAGED" ]; then
+ # Never allow a real .env (only *.example templates are tracked).
+ echo "$STAGED" | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$' >/tmp/ii_env_hits 2>/dev/null || true
+ if [ -s /tmp/ii_env_hits ]; then
+ echo "[pre-commit] BLOCKED: attempting to commit an env/secret file:"
+ cat /tmp/ii_env_hits
+ echo " -> add it to .gitignore or commit .env.example instead."
+ exit 1
+ fi
+
+ # Heuristic secret scan on added lines (private keys, obvious credential assigns).
+ if git diff --cached --unified=0 -- $STAGED \
+ | grep -E '^\+' \
+ | grep -Ei 'BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|(client_secret|password|api[_-]?key|secret)\s*[:=]\s*["'"'"']?[A-Za-z0-9/_+=-]{16,}' \
+ | grep -Evi 'change-me|your-|example|placeholder|\$\{' >/tmp/ii_secret_hits 2>/dev/null; then
+ echo "[pre-commit] BLOCKED: possible hard-coded secret in staged changes:"
+ cat /tmp/ii_secret_hits
+ echo " -> use deploy/.env / user-secrets. Override with 'git commit --no-verify' if this is a false positive."
+ exit 1
+ fi
+fi
+
+# 2) Format .NET code (only if the tool + staged .cs files are present). Verify-only,
+# non-mutating, so it never rewrites files out from under your staged diff.
+if echo "$STAGED" | grep -q '\.cs$'; then
+ if command -v dotnet >/dev/null 2>&1 && dotnet format --help >/dev/null 2>&1; then
+ echo "[pre-commit] dotnet format --verify-no-changes"
+ dotnet format InboxIntel.sln --verify-no-changes --verbosity quiet \
+ || { echo " -> run 'dotnet format InboxIntel.sln' and re-stage."; exit 1; }
+ fi
+fi
+
+echo "[pre-commit] OK"
diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push
new file mode 100755
index 0000000..0827fc1
--- /dev/null
+++ b/scripts/git-hooks/pre-push
@@ -0,0 +1,21 @@
+#!/usr/bin/env sh
+# InboxIntel pre-push hook — build + test gate. Mirrors what Gitea CI runs so you
+# catch failures before they reach the server. Bypass with: git push --no-verify
+set -eu
+
+echo "[pre-push] building solution (Release)..."
+dotnet build InboxIntel.sln -c Release --nologo \
+ || { echo "[pre-push] BLOCKED: backend build failed."; exit 1; }
+
+echo "[pre-push] running tests..."
+dotnet test InboxIntel.sln -c Release --no-build --nologo \
+ || { echo "[pre-push] BLOCKED: tests failed."; exit 1; }
+
+# Frontend build (only if the app is present and npm is installed).
+if [ -f frontend/package.json ] && command -v npm >/dev/null 2>&1; then
+ echo "[pre-push] frontend build..."
+ ( cd frontend && npm run build --silent ) \
+ || { echo "[pre-push] BLOCKED: frontend build failed."; exit 1; }
+fi
+
+echo "[pre-push] OK — safe to push."
diff --git a/scripts/install-hooks.ps1 b/scripts/install-hooks.ps1
new file mode 100644
index 0000000..122b037
--- /dev/null
+++ b/scripts/install-hooks.ps1
@@ -0,0 +1,24 @@
+<#
+.SYNOPSIS
+ Point git at the version-controlled hooks in scripts/git-hooks.
+.DESCRIPTION
+ Uses `git config core.hooksPath` so the hooks live in the repo (reviewable,
+ shared, updatable) instead of the un-tracked .git/hooks directory. Run once
+ per clone. Git for Windows ships the bash needed to execute the POSIX hooks.
+.EXAMPLE
+ ./scripts/install-hooks.ps1
+#>
+$ErrorActionPreference = 'Stop'
+$root = Split-Path -Parent $PSScriptRoot
+Push-Location $root
+try {
+ git config core.hooksPath scripts/git-hooks
+ # Best-effort exec bit (matters on Linux/WSL; harmless on Windows). Only applies
+ # once the hooks are tracked; ignored on a first run before they're committed.
+ foreach ($h in 'pre-commit','pre-push') {
+ try { git update-index --chmod=+x "scripts/git-hooks/$h" 2>$null } catch {}
+ }
+ Write-Host "Installed git hooks -> scripts/git-hooks (core.hooksPath set)." -ForegroundColor Green
+ Write-Host "Bypass in an emergency with --no-verify." -ForegroundColor DarkGray
+}
+finally { Pop-Location }
diff --git a/src/InboxIntel.Application/InboxIntel.Application.csproj b/src/InboxIntel.Application/InboxIntel.Application.csproj
index cf93528..ed04e5a 100644
--- a/src/InboxIntel.Application/InboxIntel.Application.csproj
+++ b/src/InboxIntel.Application/InboxIntel.Application.csproj
@@ -6,10 +6,13 @@
-
+
+
+
+
diff --git a/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj b/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj
index 01875f8..37f6a69 100644
--- a/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj
+++ b/src/InboxIntel.Infrastructure/InboxIntel.Infrastructure.csproj
@@ -19,7 +19,13 @@
-
+
+
+
+
+