Add persistent settings system with database backend and comprehensive UI

- New Setting ORM model (key-value store with category, value_type, audit fields)
- Alembic migration to create the settings table
- Settings API endpoints: GET/PUT /api/v1/settings/{key}, GET /api/v1/settings (list+filter), POST /api/v1/settings/bulk
- Default seeding (17 sensible defaults across general/dmarc/dns/cloudflare/notifications categories)
- Secret redaction for cloudflare.api_token and notifications.smtp_password
- Updated settings.html: General, DMARC Policy Defaults, DNS Resolver, Cloudflare Integration, Email Notifications sections
- All forms wired to the API via Alpine.js with flash feedback
- 12 new tests for the settings model and endpoints

Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/19dbc6cd-07cb-406e-b3b6-411f7721f737

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-30 07:58:51 +00:00
parent 12305fbaef
commit 241713083b
9 changed files with 957 additions and 66 deletions
+1
View File
@@ -26,6 +26,7 @@ if database_url:
from app.core.database import Base # noqa: E402
import app.models.domain # noqa: E402, F401
import app.models.report # noqa: E402, F401
import app.models.setting # noqa: E402, F401
import app.models.user # noqa: E402, F401
target_metadata = Base.metadata
@@ -0,0 +1,38 @@
"""add settings table
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-03-30 07:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c3d4e5f6a7b8"
down_revision: Union[str, Sequence[str], None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the settings table."""
op.create_table(
"settings",
sa.Column("key", sa.String(100), nullable=False),
sa.Column("value", sa.Text(), nullable=True),
sa.Column("description", sa.String(255), nullable=True),
sa.Column("value_type", sa.String(20), nullable=False, server_default="string"),
sa.Column("category", sa.String(50), nullable=False, server_default="general"),
sa.Column("updated_at", sa.DateTime(), nullable=True),
sa.Column("updated_by", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(["updated_by"], ["users.id"]),
sa.PrimaryKeyConstraint("key"),
)
def downgrade() -> None:
"""Drop the settings table."""
op.drop_table("settings")
+11 -1
View File
@@ -1,6 +1,15 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints import domains, health, imap, mail_sources, reports, setup, stats
from app.api.api_v1.endpoints import (
domains,
health,
imap,
mail_sources,
reports,
settings,
setup,
stats,
)
api_router = APIRouter()
@@ -12,3 +21,4 @@ api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
@@ -0,0 +1,358 @@
"""
Settings API endpoints.
Provides endpoints to read and write application-level settings persisted
in the ``settings`` database table. Settings are organised into categories:
- ``general`` App name, base URL, reports-per-page, etc.
- ``dmarc`` Default DMARC policy, percentage, etc.
- ``dns`` Default DNS resolver, Cloudflare DoH toggle.
- ``cloudflare`` Cloudflare API token and Zone ID.
- ``notifications`` Future alerting/notification settings.
"""
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.setting import Setting
router = APIRouter()
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Defaults used to seed missing keys on first read
# ---------------------------------------------------------------------------
SETTING_DEFAULTS: List[Dict[str, Any]] = [
# ── General ─────────────────────────────────────────────────────────────
{
"key": "general.app_name",
"value": "DMARQ",
"description": "Application display name shown in the UI",
"value_type": "string",
"category": "general",
},
{
"key": "general.base_url",
"value": "",
"description": "Public base URL (e.g. https://dmarc.example.com)",
"value_type": "string",
"category": "general",
},
{
"key": "general.reports_per_page",
"value": "25",
"description": "Number of reports shown per page in the reports list",
"value_type": "integer",
"category": "general",
},
{
"key": "general.session_lifetime_minutes",
"value": "1440",
"description": "How long a login session stays valid (minutes)",
"value_type": "integer",
"category": "general",
},
# ── DMARC ────────────────────────────────────────────────────────────────
{
"key": "dmarc.default_policy",
"value": "none",
"description": "Default DMARC policy applied when adding a new domain",
"value_type": "string",
"category": "dmarc",
},
{
"key": "dmarc.default_percentage",
"value": "100",
"description": "Default DMARC percentage (pct) tag for new domains",
"value_type": "integer",
"category": "dmarc",
},
{
"key": "dmarc.default_adkim",
"value": "r",
"description": "Default DKIM alignment mode: r (relaxed) or s (strict)",
"value_type": "string",
"category": "dmarc",
},
{
"key": "dmarc.default_aspf",
"value": "r",
"description": "Default SPF alignment mode: r (relaxed) or s (strict)",
"value_type": "string",
"category": "dmarc",
},
# ── DNS ──────────────────────────────────────────────────────────────────
{
"key": "dns.resolver",
"value": "system",
"description": "DNS resolver to use: system or cloudflare",
"value_type": "string",
"category": "dns",
},
# ── Cloudflare ───────────────────────────────────────────────────────────
{
"key": "cloudflare.api_token",
"value": "",
"description": "Cloudflare API token for DNS record management",
"value_type": "string",
"category": "cloudflare",
},
{
"key": "cloudflare.zone_id",
"value": "",
"description": "Cloudflare Zone ID for DNS record management",
"value_type": "string",
"category": "cloudflare",
},
# ── Notifications ─────────────────────────────────────────────────────────
{
"key": "notifications.email_enabled",
"value": "false",
"description": "Send email notifications when new DMARC failures are detected",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.email_from",
"value": "",
"description": "From address used for notification emails",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.email_to",
"value": "",
"description": "Comma-separated list of recipient addresses for notifications",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.smtp_host",
"value": "",
"description": "SMTP server hostname for sending notification emails",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.smtp_port",
"value": "587",
"description": "SMTP server port",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.smtp_username",
"value": "",
"description": "SMTP authentication username",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.smtp_password",
"value": "",
"description": "SMTP authentication password",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.smtp_use_tls",
"value": "true",
"description": "Use TLS when connecting to the SMTP server",
"value_type": "boolean",
"category": "notifications",
},
]
# Keys whose values should be redacted in GET responses (treated as secrets)
_SECRET_KEYS = {
"cloudflare.api_token",
"notifications.smtp_password",
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_defaults(db: Session) -> None:
"""Insert any missing default settings rows (idempotent)."""
for defaults in SETTING_DEFAULTS:
key = defaults["key"]
if db.query(Setting).filter(Setting.key == key).first() is None:
db.add(
Setting(
key=key,
value=defaults["value"],
description=defaults["description"],
value_type=defaults["value_type"],
category=defaults["category"],
)
)
db.commit()
def _get_setting(key: str, db: Session) -> Optional[Setting]:
return db.query(Setting).filter(Setting.key == key).first()
def _row_to_dict(row: Setting, redact_secrets: bool = True) -> Dict[str, Any]:
value = row.value
if redact_secrets and row.key in _SECRET_KEYS and value:
value = "**redacted**"
return {
"key": row.key,
"value": value,
"description": row.description,
"value_type": row.value_type,
"category": row.category,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class SettingUpdate(BaseModel):
"""Payload for updating a single setting."""
value: Optional[str] = None
class BulkSettingsUpdate(BaseModel):
"""Payload for updating multiple settings at once."""
settings: Dict[str, Optional[str]]
class SettingResponse(BaseModel):
"""Response for a single setting."""
key: str
value: Optional[str]
description: Optional[str]
value_type: str
category: str
updated_at: Optional[str]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=List[SettingResponse])
async def list_settings(
category: Optional[str] = None,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> List[SettingResponse]:
"""
Return all persisted settings, optionally filtered by category.
Missing rows are seeded from defaults before returning.
"""
_seed_defaults(db)
query = db.query(Setting)
if category:
query = query.filter(Setting.category == category)
rows = query.order_by(Setting.category, Setting.key).all()
return [_row_to_dict(row) for row in rows]
@router.get("/{key:path}", response_model=SettingResponse)
async def get_setting(
key: str,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> SettingResponse:
"""Return a single setting by key."""
_seed_defaults(db)
row = _get_setting(key, db)
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found",
)
return _row_to_dict(row)
@router.put("/{key:path}", response_model=SettingResponse)
async def update_setting(
key: str,
payload: SettingUpdate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> SettingResponse:
"""Update or create a single setting."""
row = _get_setting(key, db)
if row is None:
# Find matching default metadata
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
row = Setting(
key=key,
value=payload.value,
description=default_meta["description"] if default_meta else None,
value_type=default_meta["value_type"] if default_meta else "string",
category=default_meta["category"] if default_meta else "general",
)
db.add(row)
else:
# For secret keys, only update if not the redacted placeholder
if key in _SECRET_KEYS and payload.value == "**redacted**":
db.refresh(row)
return _row_to_dict(row)
row.value = payload.value
db.commit()
db.refresh(row)
return _row_to_dict(row)
@router.post("/bulk", response_model=List[SettingResponse])
async def bulk_update_settings(
payload: BulkSettingsUpdate,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> List[SettingResponse]:
"""
Update multiple settings in a single request.
Accepts ``{"settings": {"key1": "value1", "key2": "value2", ...}}``.
"""
results = []
for key, value in payload.settings.items():
row = _get_setting(key, db)
if row is None:
default_meta = next((d for d in SETTING_DEFAULTS if d["key"] == key), None)
row = Setting(
key=key,
value=value,
description=default_meta["description"] if default_meta else None,
value_type=default_meta["value_type"] if default_meta else "string",
category=default_meta["category"] if default_meta else "general",
)
db.add(row)
else:
# Skip secret placeholder updates
if key in _SECRET_KEYS and value == "**redacted**":
results.append(_row_to_dict(row))
continue
row.value = value
results.append(_row_to_dict(row))
db.commit()
# Re-read rows to get updated_at timestamps
refreshed = []
for item in results:
row = _get_setting(item["key"], db)
if row:
refreshed.append(_row_to_dict(row))
return refreshed
+1
View File
@@ -11,6 +11,7 @@ from fastapi.templating import Jinja2Templates
import app.models.domain # noqa: F401 ensure Domain/UserDomain tables are registered
import app.models.report # noqa: F401 ensure DMARCReport/ReportRecord tables are registered
import app.models.setting # noqa: F401 ensure Setting table is registered
import app.models.user # noqa: F401 ensure User table is registered
from app.api.api_v1.api import api_router
from app.core.config import get_settings
+34
View File
@@ -0,0 +1,34 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from app.core.database import Base
class Setting(Base):
"""
Key-value store for system-wide application settings.
Settings are grouped by a ``category`` prefix (e.g. ``general``,
``dmarc``, ``cloudflare``) to make bulk retrieval and UI grouping easy.
The ``value`` is always stored as text; callers are responsible for
serialising/deserialising typed values (int, bool, JSON) via the
``value_type`` hint.
"""
__tablename__ = "settings"
key = Column(String(100), primary_key=True)
value = Column(Text, nullable=True)
# Human-readable description shown in the admin UI
description = Column(String(255), nullable=True)
# Hint for the UI / API on how to interpret the value: string | integer | boolean | json
value_type = Column(String(20), nullable=False, default="string")
# Category / section grouping (e.g. "general", "dmarc", "cloudflare", "dns")
category = Column(String(50), nullable=False, default="general")
# Audit fields
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_by = Column(Integer, ForeignKey("users.id"), nullable=True)
def __repr__(self):
return f"<Setting key={self.key!r} category={self.category!r}>"
+363 -65
View File
@@ -9,16 +9,311 @@
{% block page_title %}Settings{% endblock %}
{% block content %}
<div class="grid gap-4 md:gap-8 py-4">
<div
x-data="settingsApp()"
x-init="loadSettings()"
class="space-y-6 py-4"
>
<!-- Mail Sources info card (replaces the old IMAP configuration form) -->
<!-- Flash message -->
<template x-if="flashMsg">
<div :class="flashOk ? 'alert alert-success' : 'alert alert-error'" class="shadow-sm">
<span x-text="flashMsg"></span>
</div>
</template>
<!-- ── General ──────────────────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}General{% endcall %}
{% call card_description() %}Basic application settings{% endcall %}
{% endcall %}
{% call card_content() %}
<form @submit.prevent="saveCategory('general')" class="space-y-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Application Name</span></label>
<input type="text" x-model="s['general.app_name']"
class="input input-bordered w-full"
placeholder="DMARQ" />
<label class="label"><span class="label-text-alt text-muted-foreground">Display name shown in the navigation bar and page titles</span></label>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Base URL</span></label>
<input type="url" x-model="s['general.base_url']"
class="input input-bordered w-full"
placeholder="https://dmarc.example.com" />
<label class="label"><span class="label-text-alt text-muted-foreground">Public URL used in OAuth2 redirect URIs and email links</span></label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Reports Per Page</span></label>
<input type="number" x-model.number="s['general.reports_per_page']"
class="input input-bordered w-full" min="5" max="200" />
<label class="label"><span class="label-text-alt text-muted-foreground">How many reports are shown per page</span></label>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Session Lifetime (minutes)</span></label>
<input type="number" x-model.number="s['general.session_lifetime_minutes']"
class="input input-bordered w-full" min="5" />
<label class="label"><span class="label-text-alt text-muted-foreground">How long login sessions remain valid</span></label>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
<template x-if="!saving">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</template>
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Save General Settings
</button>
</div>
</form>
{% endcall %}
{% endcall %}
<!-- ── DMARC Policy Defaults ─────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}DMARC Policy Defaults{% endcall %}
{% call card_description() %}Default values applied when adding a new domain{% endcall %}
{% endcall %}
{% call card_content() %}
<form @submit.prevent="saveCategory('dmarc')" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Default Policy</span></label>
<select x-model="s['dmarc.default_policy']" class="input input-bordered w-full">
<option value="none">None (monitoring only)</option>
<option value="quarantine">Quarantine (send to spam)</option>
<option value="reject">Reject (block delivery)</option>
</select>
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Default Percentage</span>
<span class="label-text-alt" x-text="(s['dmarc.default_percentage'] || 100) + '%'"></span>
</label>
<input type="range" x-model.number="s['dmarc.default_percentage']"
min="0" max="100"
class="range range-primary w-full" />
<label class="label"><span class="label-text-alt text-muted-foreground">Percentage of messages to which the policy is applied (pct tag)</span></label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">DKIM Alignment (adkim)</span></label>
<select x-model="s['dmarc.default_adkim']" class="input input-bordered w-full">
<option value="r">Relaxed</option>
<option value="s">Strict</option>
</select>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SPF Alignment (aspf)</span></label>
<select x-model="s['dmarc.default_aspf']" class="input input-bordered w-full">
<option value="r">Relaxed</option>
<option value="s">Strict</option>
</select>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
<template x-if="!saving">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</template>
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Save DMARC Defaults
</button>
</div>
</form>
{% endcall %}
{% endcall %}
<!-- ── DNS Resolver ──────────────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}DNS Resolver{% endcall %}
{% call card_description() %}Choose how DMARQ resolves DNS records for domain lookups{% endcall %}
{% endcall %}
{% call card_content() %}
<form @submit.prevent="saveCategory('dns')" class="space-y-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">DNS Resolver</span></label>
<select x-model="s['dns.resolver']" class="input input-bordered w-full">
<option value="system">System (OS default)</option>
<option value="cloudflare">Cloudflare DoH (1.1.1.1)</option>
</select>
<label class="label"><span class="label-text-alt text-muted-foreground">System resolver uses the OS-configured DNS server; Cloudflare uses DNS-over-HTTPS</span></label>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
<template x-if="!saving">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</template>
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Save DNS Settings
</button>
</div>
</form>
{% endcall %}
{% endcall %}
<!-- ── Cloudflare Integration ─────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}Cloudflare Integration{% endcall %}
{% call card_description() %}
Provide a Cloudflare API token and Zone ID to enable automated DNS record management and DoH lookups.
Obtain your token from <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" class="underline">Cloudflare API Tokens</a>.
{% endcall %}
{% endcall %}
{% call card_content() %}
<form @submit.prevent="saveCategory('cloudflare')" class="space-y-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">API Token</span></label>
<div class="relative">
<input :type="showCfToken ? 'text' : 'password'"
x-model="s['cloudflare.api_token']"
class="input input-bordered w-full pr-10"
placeholder="Your Cloudflare API token" />
<button type="button"
class="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
@click="showCfToken = !showCfToken">
<svg x-show="!showCfToken" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
<svg x-show="showCfToken" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
</button>
</div>
<label class="label"><span class="label-text-alt text-muted-foreground">Stored securely; leave as-is to keep existing token</span></label>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Zone ID</span></label>
<input type="text" x-model="s['cloudflare.zone_id']"
class="input input-bordered w-full"
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
<label class="label"><span class="label-text-alt text-muted-foreground">Found on the Cloudflare dashboard overview for your domain</span></label>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
<template x-if="!saving">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</template>
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Save Cloudflare Settings
</button>
</div>
</form>
{% endcall %}
{% endcall %}
<!-- ── Email Notifications ─────────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}Email Notifications{% endcall %}
{% call card_description() %}Send alerts when DMARC failures are detected{% endcall %}
{% endcall %}
{% call card_content() %}
<form @submit.prevent="saveCategory('notifications')" class="space-y-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.email_enabled'] === 'true'"
@change="s['notifications.email_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Enable email notifications</span>
</label>
</div>
<template x-if="s['notifications.email_enabled'] === 'true'">
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">From Address</span></label>
<input type="email" x-model="s['notifications.email_from']"
class="input input-bordered w-full"
placeholder="noreply@example.com" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Recipient(s)</span></label>
<input type="text" x-model="s['notifications.email_to']"
class="input input-bordered w-full"
placeholder="admin@example.com, security@example.com" />
<label class="label"><span class="label-text-alt text-muted-foreground">Comma-separated email addresses</span></label>
</div>
</div>
<div class="divider text-sm">SMTP Configuration</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SMTP Host</span></label>
<input type="text" x-model="s['notifications.smtp_host']"
class="input input-bordered w-full"
placeholder="smtp.example.com" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SMTP Port</span></label>
<input type="number" x-model.number="s['notifications.smtp_port']"
class="input input-bordered w-full"
placeholder="587" min="1" max="65535" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SMTP Username</span></label>
<input type="text" x-model="s['notifications.smtp_username']"
class="input input-bordered w-full"
placeholder="smtpuser@example.com" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SMTP Password</span></label>
<div class="relative">
<input :type="showSmtpPw ? 'text' : 'password'"
x-model="s['notifications.smtp_password']"
class="input input-bordered w-full pr-10"
placeholder="••••••••" />
<button type="button"
class="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
@click="showSmtpPw = !showSmtpPw">
<svg x-show="!showSmtpPw" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
<svg x-show="showSmtpPw" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
</button>
</div>
<label class="label"><span class="label-text-alt text-muted-foreground">Leave as-is to keep existing password</span></label>
</div>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.smtp_use_tls'] === 'true'"
@change="s['notifications.smtp_use_tls'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Use TLS (STARTTLS)</span>
</label>
</div>
</div>
</template>
<div class="flex justify-end">
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
<template x-if="!saving">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
</template>
<template x-if="saving"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Save Notification Settings
</button>
</div>
</form>
{% endcall %}
{% endcall %}
<!-- ── Mail Sources shortcut ──────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}Mail Sources{% endcall %}
{% call card_description() %}
IMAP and other inbox credentials are now managed on the dedicated
<strong>Mail Sources</strong> page. Multiple accounts and methods
(IMAP, POP3, Gmail API) can be configured there.
IMAP, POP3 and Gmail API inbox credentials are managed on the dedicated
<strong>Mail Sources</strong> page.
{% endcall %}
{% endcall %}
{% call card_content() %}
@@ -33,71 +328,74 @@
{% endcall %}
{% endcall %}
<!-- DMARC Policy Management -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}DMARC Policy Management{% endcall %}
{% call card_description() %}
Configure default DMARC policy settings for newly added domains
{% endcall %}
{% endcall %}
{% call card_content() %}
<form id="dmarc-policy-form" class="space-y-6" x-data="{isUpdating: false, updateResult: ''}">
<div class="space-y-4">
{% call form_group() %}
{% call label(for="default_policy") %}Default DMARC Policy{% endcall %}
<select id="default_policy" name="default_policy" class="input w-full">
<option value="none">None (monitoring only)</option>
<option value="quarantine">Quarantine (send to spam)</option>
<option value="reject">Reject (block delivery)</option>
</select>
<p class="text-xs text-muted-foreground mt-1">Policy applied to new domains when no specific policy is set</p>
{% endcall %}
{% call form_group() %}
{% call label(for="percent") %}Percentage{% endcall %}
<div class="flex items-center">
<input type="range" id="percent" name="percent" min="0" max="100" value="100" class="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer" />
<span class="ml-2 text-sm font-medium w-10" id="percent-display">100%</span>
</div>
<p class="text-xs text-muted-foreground mt-1">Percentage of messages to which the DMARC policy is applied</p>
{% endcall %}
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-default btn-md">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
Save Policy Settings
</button>
</div>
</form>
{% endcall %}
{% endcall %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Initialize any scripts after DOM load
document.addEventListener('DOMContentLoaded', function() {
// Percent display for DMARC policy form
const percentInput = document.getElementById('percent');
const percentDisplay = document.getElementById('percent-display');
if (percentInput && percentDisplay) {
percentInput.addEventListener('input', function() {
percentDisplay.textContent = this.value + '%';
});
}
function settingsApp() {
return {
s: {}, // flat map of key → value (strings)
saving: false,
flashMsg: '',
flashOk: true,
showCfToken: false,
showSmtpPw: false,
// DMARC policy form handling
const policyForm = document.getElementById('dmarc-policy-form');
if (policyForm) {
policyForm.addEventListener('submit', function(e) {
e.preventDefault();
// In a real app, you would save the policy settings here
alert('DMARC policy settings saved');
});
}
});
apiHeaders() {
const key = localStorage.getItem('adminApiKey') || '';
return { 'Content-Type': 'application/json', 'X-API-Key': key };
},
async loadSettings() {
try {
const res = await fetch('/api/v1/settings', { headers: this.apiHeaders() });
if (!res.ok) {
this.showFlash('Failed to load settings: ' + res.statusText, false);
return;
}
const rows = await res.json();
const map = {};
rows.forEach(r => { map[r.key] = r.value ?? ''; });
this.s = map;
} catch (err) {
this.showFlash('Error loading settings: ' + err.message, false);
}
},
async saveCategory(category) {
this.saving = true;
// Collect all keys that belong to this category
const categoryKeys = Object.keys(this.s).filter(k => k.startsWith(category + '.'));
const settings = {};
categoryKeys.forEach(k => { settings[k] = String(this.s[k] ?? ''); });
try {
const res = await fetch('/api/v1/settings/bulk', {
method: 'POST',
headers: this.apiHeaders(),
body: JSON.stringify({ settings }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
this.showFlash('Save failed: ' + (data.detail || res.statusText), false);
} else {
const rows = await res.json();
rows.forEach(r => { this.s[r.key] = r.value ?? ''; });
this.showFlash('Settings saved successfully.', true);
}
} catch (err) {
this.showFlash('Error saving settings: ' + err.message, false);
} finally {
this.saving = false;
}
},
showFlash(msg, ok) {
this.flashMsg = msg;
this.flashOk = ok;
setTimeout(() => { this.flashMsg = ''; }, 4000);
},
};
}
</script>
{% endblock %}
+1
View File
@@ -9,6 +9,7 @@ from sqlalchemy.pool import StaticPool
import app.models.domain # noqa: F401 # pylint: disable=unused-import
import app.models.mail_source as _mail_source_model # noqa: F401 # pylint: disable=unused-import
import app.models.report # noqa: F401 # pylint: disable=unused-import
import app.models.setting # noqa: F401 # pylint: disable=unused-import
import app.models.user # noqa: F401 # pylint: disable=unused-import
from app.core.database import Base, get_db
from app.core.security import require_admin_auth
+150
View File
@@ -0,0 +1,150 @@
"""
Tests for the Settings model and /api/v1/settings endpoints.
"""
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.setting import Setting
class TestSettingModel:
"""Unit tests for the Setting ORM model."""
def test_create_setting(self, db_session: Session):
row = Setting(
key="general.app_name",
value="TestApp",
description="App name",
value_type="string",
category="general",
)
db_session.add(row)
db_session.commit()
db_session.refresh(row)
assert row.key == "general.app_name"
assert row.value == "TestApp"
assert row.category == "general"
assert row.value_type == "string"
def test_repr(self, db_session: Session):
row = Setting(key="dns.resolver", value="system", category="dns")
db_session.add(row)
db_session.commit()
assert "dns.resolver" in repr(row)
assert "dns" in repr(row)
class TestSettingsAPI:
"""Integration tests for /api/v1/settings endpoints."""
def test_list_settings_seeds_defaults(self, authed_client: TestClient):
"""GET /api/v1/settings returns seeded defaults on first call."""
res = authed_client.get("/api/v1/settings")
assert res.status_code == 200
data = res.json()
assert isinstance(data, list)
keys = {row["key"] for row in data}
assert "general.app_name" in keys
assert "dmarc.default_policy" in keys
assert "cloudflare.api_token" in keys
def test_list_settings_filter_by_category(self, authed_client: TestClient):
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
res = authed_client.get("/api/v1/settings?category=dmarc")
assert res.status_code == 200
data = res.json()
for row in data:
assert row["category"] == "dmarc"
def test_get_single_setting(self, authed_client: TestClient):
"""GET /api/v1/settings/{key} returns a single setting."""
# Seed defaults first
authed_client.get("/api/v1/settings")
res = authed_client.get("/api/v1/settings/general.app_name")
assert res.status_code == 200
assert res.json()["key"] == "general.app_name"
assert res.json()["value"] == "DMARQ"
def test_get_missing_setting_returns_404(self, authed_client: TestClient):
"""GET /api/v1/settings/{key} returns 404 for unknown keys."""
authed_client.get("/api/v1/settings") # seed
res = authed_client.get("/api/v1/settings/nonexistent.key")
assert res.status_code == 404
def test_update_setting(self, authed_client: TestClient):
"""PUT /api/v1/settings/{key} updates a setting value."""
authed_client.get("/api/v1/settings") # seed
res = authed_client.put(
"/api/v1/settings/general.app_name",
json={"value": "MyDMARQ"},
)
assert res.status_code == 200
assert res.json()["value"] == "MyDMARQ"
# Verify persistence
res2 = authed_client.get("/api/v1/settings/general.app_name")
assert res2.json()["value"] == "MyDMARQ"
def test_update_setting_upserts(self, authed_client: TestClient):
"""PUT /api/v1/settings/{key} creates the row if it doesn't exist yet."""
res = authed_client.put(
"/api/v1/settings/general.custom_key",
json={"value": "hello"},
)
assert res.status_code == 200
assert res.json()["value"] == "hello"
def test_bulk_update(self, authed_client: TestClient):
"""POST /api/v1/settings/bulk updates multiple settings at once."""
authed_client.get("/api/v1/settings") # seed
res = authed_client.post(
"/api/v1/settings/bulk",
json={
"settings": {
"dmarc.default_policy": "quarantine",
"dmarc.default_percentage": "80",
}
},
)
assert res.status_code == 200
data = {row["key"]: row["value"] for row in res.json()}
assert data["dmarc.default_policy"] == "quarantine"
assert data["dmarc.default_percentage"] == "80"
def test_secret_is_redacted_in_response(self, authed_client: TestClient):
"""cloudflare.api_token value is redacted in GET responses."""
authed_client.get("/api/v1/settings") # seed
# Store a real token
authed_client.put(
"/api/v1/settings/cloudflare.api_token",
json={"value": "super-secret-token"},
)
res = authed_client.get("/api/v1/settings/cloudflare.api_token")
assert res.status_code == 200
assert res.json()["value"] == "**redacted**"
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
"""Sending **redacted** back to PUT should not overwrite the stored value."""
authed_client.get("/api/v1/settings")
authed_client.put(
"/api/v1/settings/cloudflare.api_token",
json={"value": "real-token-value"},
)
# Simulate round-trip with redacted placeholder
authed_client.put(
"/api/v1/settings/cloudflare.api_token",
json={"value": "**redacted**"},
)
# Direct DB check via a fresh GET the value should still be "real-token-value"
# (GET always redacts, so we check via the list endpoint's category filter)
res = authed_client.get("/api/v1/settings?category=cloudflare")
cf = {row["key"]: row["value"] for row in res.json()}
# Value should remain redacted (which means the underlying value is still set)
assert cf["cloudflare.api_token"] == "**redacted**"
def test_unauthenticated_returns_403(self, client: TestClient):
"""Unauthenticated requests to settings endpoints return 403."""
res = client.get("/api/v1/settings")
assert res.status_code in (401, 403)