Add initial MVP documentation for DMARQ platform, detailing backend architecture, frontend implementation, and deployment structure

This commit is contained in:
Christian Krakau-Louis
2025-04-17 15:20:42 +02:00
parent 363a31c02d
commit f910cb0ba4
33 changed files with 4176 additions and 14 deletions
+41 -14
View File
@@ -17,6 +17,24 @@ No more guessing. See which services are passing DMARC, which are failing, and h
---
## 🚀 Current Status - Milestone 1 Completed
We have achieved **Milestone 1: Basic DMARC Monitoring**. This milestone includes:
- ✅ DMARC XML report parsing (supports XML, ZIP, and GZIP formats)
- ✅ In-memory storage of report data for up to 5 domains
- ✅ Simple dashboard UI showing DMARC compliance statistics
- ✅ Support for uploading and processing DMARC aggregate reports
- ✅ Domain overview with compliance rates and email statistics
You can now:
1. Upload DMARC aggregate reports via the web interface
2. View summary statistics across all monitored domains
3. Drill down into domain-specific details and reports
4. Track compliance rates and authentication failures
---
## ✨ Key Features
### 📊 Dashboard & Reports
@@ -66,28 +84,37 @@ cp .env.example .env
docker compose up --build
```
Then visit [http://localhost:8000](http://localhost:8000) to launch the config wizard and connect your inbox & Cloudflare account.
Then visit [http://localhost](http://localhost) to access the dashboard and upload your DMARC reports.
### Development Setup
For development without Docker:
```bash
cd backend
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8080
```
Then visit [http://localhost:8080](http://localhost:8080)
---
## 📦 Requirements
- IMAP inbox for DMARC reports (e.g. `dmarc@yourdomain.com`)
- Cloudflare API token (read access to zones and DNS)
- Docker + Docker Compose
- DMARC reporting enabled for your domain (via `rua=` and `ruf=`)
- DMARC aggregate reports (XML, ZIP, or GZIP format)
- Docker + Docker Compose (for production deployment)
- Python 3.10+ (for development)
---
## 🧪 Status
## 🧪 Development Roadmap
-DMARC aggregate & forensic report ingestion
- ✅ Dashboard & alerting
- ✅ Web-based config wizard
- ✅ Docker-based deployment
- 🔜 Rule-based alerts
- 🔜 Multi-tenant support
- 🔜 Self-updating DNS snapshot history
-**Milestone 1**: Basic DMARC Monitoring (up to 5 domains)
- 🔄 **Milestone 2**: Enhanced Visualization & Analysis
- 🔜 **Milestone 3**: Database Persistence & User Management
- 🔜 **Milestone 4**: Email Integration & Automated Processing
- 🔜 **Milestone 5**: DNS Health & Configuration Suggestions
---
@@ -111,4 +138,4 @@ Unlike most commercial DMARC tools, DMARQ gives you:
- 🎨 A beautiful, intuitive dashboard with real-time insights
- 💻 Self-hosted flexibility with modern developer practices
Lets build better email security — together.
Let's build better email security — together.
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.10-slim
WORKDIR /app
# Install required system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose the port the app runs on
EXPOSE 8080
# Command to run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
+11
View File
@@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints import domains, health, reports, setup
api_router = APIRouter()
# Include all endpoint routers
api_router.include_router(health.router, tags=["health"])
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
+122
View File
@@ -0,0 +1,122 @@
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from app.services.report_store import ReportStore
router = APIRouter()
class DomainBase(BaseModel):
"""Base Domain schema"""
name: str
description: Optional[str] = None
policy: Optional[str] = None
class DomainResponse(DomainBase):
"""Domain response schema"""
reports_count: int = 0
emails_count: int = 0
compliance_rate: float = 0.0
class DomainSummaryResponse(BaseModel):
"""Domain summary for dashboard"""
total_domains: int
total_emails: int
overall_pass_rate: float
reports_processed: int
domains: List[Dict[str, Any]]
@router.get("/summary", response_model=DomainSummaryResponse)
async def get_domains_summary():
"""
Get summary statistics for all domains, formatted for the dashboard.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
summaries = store.get_all_domain_summaries()
# Calculate overall statistics
total_domains = len(domains)
total_emails = 0
total_passed = 0
total_reports = 0
domains_list = []
for domain_name in domains:
summary = summaries.get(domain_name, {})
total_emails += summary.get("total_count", 0)
total_passed += summary.get("passed_count", 0)
total_reports += summary.get("reports_processed", 0)
# Format domain data for frontend
domains_list.append({
"id": domain_name, # Using the domain name as ID for now
"domain_name": domain_name,
"total_emails": summary.get("total_count", 0),
"passed_count": summary.get("passed_count", 0),
"failed_count": summary.get("failed_count", 0),
"pass_rate": summary.get("compliance_rate", 0),
"report_count": summary.get("reports_processed", 0)
})
# Calculate overall pass rate
overall_pass_rate = 0
if total_emails > 0:
overall_pass_rate = round((total_passed / total_emails) * 100, 1)
return DomainSummaryResponse(
total_domains=total_domains,
total_emails=total_emails,
overall_pass_rate=overall_pass_rate,
reports_processed=total_reports,
domains=domains_list
)
@router.get("/domains", response_model=List[DomainResponse])
async def read_domains():
"""
Retrieve domains with their statistics.
For Milestone 1, this simply returns domains from the in-memory store.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
summaries = store.get_all_domain_summaries()
result = []
for domain_name in domains:
summary = summaries.get(domain_name, {})
domain_response = DomainResponse(
name=domain_name,
policy=summary.get("policy", "unknown"),
reports_count=summary.get("reports_processed", 0),
emails_count=summary.get("total_count", 0),
compliance_rate=summary.get("compliance_rate", 0.0)
)
result.append(domain_response)
return result
@router.get("/domains/{domain_name}", response_model=DomainResponse)
async def read_domain(domain_name: str):
"""
Get statistics for a specific domain.
"""
store = ReportStore.get_instance()
domains = store.get_domains()
if domain_name not in domains:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found",
)
summary = store.get_domain_summary(domain_name)
return DomainResponse(
name=domain_name,
policy=summary.get("policy", "unknown"),
reports_count=summary.get("reports_processed", 0),
emails_count=summary.get("total_count", 0),
compliance_rate=summary.get("compliance_rate", 0.0)
)
@@ -0,0 +1,18 @@
from fastapi import APIRouter
from app.api.api_v1.endpoints.setup import setup_status
router = APIRouter()
@router.get("/health", status_code=200)
async def health_check():
"""
Health check endpoint to verify API status.
For Milestone 1, this simply returns status information without checking a database.
"""
return {
"status": "ok",
"version": "0.1.0",
"service": "dmarq",
"is_setup_complete": setup_status["is_setup_complete"]
}
+135
View File
@@ -0,0 +1,135 @@
from typing import Dict, List, Any
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from pydantic import BaseModel
from app.services.dmarc_parser import DMARCParser
from app.services.report_store import ReportStore
router = APIRouter()
class UploadResponse(BaseModel):
"""Response model for report upload"""
success: bool
domain: str
message: str
processed_records: int = 0 # Added this field to track processed records
class DomainSummary(BaseModel):
"""Domain summary response model"""
domain: str
total_count: int
passed_count: int
failed_count: int
reports_processed: int
compliance_rate: float
class ReportSummary(BaseModel):
"""DMARC report summary model"""
report_id: str
org_name: str
begin_date: str
end_date: str
total_count: int
passed_count: int
failed_count: int
@router.post("/upload", response_model=UploadResponse)
async def upload_report(file: UploadFile = File(...)):
"""
Upload and process a DMARC aggregate report file (XML, ZIP, or GZIP)
"""
try:
# Read the file content
file_content = await file.read()
filename = file.filename
# Parse the report
parser = DMARCParser()
report = parser.parse_file(file_content, filename)
# Store the report
store = ReportStore.get_instance()
store.add_report(report)
domain = report.get("domain", "unknown")
processed_records = report.get("summary", {}).get("total_count", 0)
return UploadResponse(
success=True,
domain=domain,
message=f"Report processed successfully for domain {domain}",
processed_records=processed_records
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Error processing report: {str(e)}"
)
@router.get("/domains", response_model=List[str])
async def get_domains():
"""
Get list of all domains with reports
"""
store = ReportStore.get_instance()
return store.get_domains()
@router.get("/domain/{domain}/summary", response_model=DomainSummary)
async def get_domain_summary(domain: str):
"""
Get summary statistics for a specific domain
"""
store = ReportStore.get_instance()
summary = store.get_domain_summary(domain)
if not summary:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No reports found for domain {domain}"
)
return DomainSummary(
domain=domain,
**summary
)
@router.get("/summary", response_model=List[DomainSummary])
async def get_all_summaries():
"""
Get summary statistics for all domains
"""
store = ReportStore.get_instance()
all_summaries = store.get_all_domain_summaries()
return [
DomainSummary(domain=domain, **summary)
for domain, summary in all_summaries.items()
]
@router.get("/domain/{domain}/reports", response_model=List[ReportSummary])
async def get_domain_reports(domain: str):
"""
Get all reports for a specific domain
"""
store = ReportStore.get_instance()
reports = store.get_domain_reports(domain)
if not reports:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"No reports found for domain {domain}"
)
return [
ReportSummary(
report_id=report.get("report_id", ""),
org_name=report.get("org_name", ""),
begin_date=report.get("begin_date", ""),
end_date=report.get("end_date", ""),
total_count=report.get("summary", {}).get("total_count", 0),
passed_count=report.get("summary", {}).get("passed_count", 0),
failed_count=report.get("summary", {}).get("failed_count", 0)
)
for report in reports
]
+65
View File
@@ -0,0 +1,65 @@
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, EmailStr
from typing import Dict, Optional
router = APIRouter()
# Simple in-memory storage for setup status (for Milestone 1)
setup_status = {
"is_setup_complete": False,
"admin_email": None,
"app_name": "DMARQ",
}
class SetupStatusResponse(BaseModel):
"""Setup status response"""
is_setup_complete: bool
app_name: str
class AdminSetupRequest(BaseModel):
"""Admin user setup request body"""
email: EmailStr
username: str
password: str
class SystemConfigRequest(BaseModel):
"""System configuration setup request body"""
app_name: str
base_url: str
@router.get("/status", response_model=SetupStatusResponse)
async def get_setup_status():
"""Get the current setup status"""
return SetupStatusResponse(
is_setup_complete=setup_status["is_setup_complete"],
app_name=setup_status["app_name"]
)
@router.post("/admin", status_code=201)
async def setup_admin(request: AdminSetupRequest):
"""
Setup admin user during initial system configuration.
For Milestone 1, this simply stores the admin email in memory.
"""
if setup_status["is_setup_complete"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Setup already completed"
)
# Store admin email
setup_status["admin_email"] = request.email
return {"message": "Admin user setup completed"}
@router.post("/system", status_code=200)
async def setup_system(request: SystemConfigRequest):
"""
Setup system configuration.
For Milestone 1, this simply stores the app name in memory.
"""
# Store app name
setup_status["app_name"] = request.app_name
setup_status["is_setup_complete"] = True
return {"message": "System settings saved successfully"}
+63
View File
@@ -0,0 +1,63 @@
from functools import lru_cache
from typing import Optional, List, Union
# Try to import from pydantic_settings first (newer versions)
try:
from pydantic_settings import BaseSettings
from pydantic import EmailStr, validator
except ImportError:
# Fall back to older pydantic version
from pydantic import BaseSettings, EmailStr, validator
class Settings(BaseSettings):
"""Application settings"""
# Base
PROJECT_NAME: str = "DMARQ"
API_V1_STR: str = "/api/v1"
# Database
DATABASE_URL: str = "sqlite:///./dmarq.db"
# JWT Authentication
SECRET_KEY: str = "CHANGE_THIS_TO_A_RANDOM_SECRET_IN_PRODUCTION"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 # 1 hour
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:5173"]
# IMAP Settings
IMAP_SERVER: Optional[str] = None
IMAP_PORT: int = 993
IMAP_USERNAME: Optional[str] = None
IMAP_PASSWORD: Optional[str] = None
# Admin User
FIRST_SUPERUSER: Optional[EmailStr] = None
FIRST_SUPERUSER_PASSWORD: Optional[str] = None
# Optional Cloudflare Integration
CLOUDFLARE_API_TOKEN: Optional[str] = None
CLOUDFLARE_ZONE_ID: Optional[str] = None
@validator("BACKEND_CORS_ORIGINS", pre=True)
def assemble_cors_origins(cls, v: Union[str, List[str]]) -> List[str]:
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",")]
elif isinstance(v, (list, str)):
return v
raise ValueError(v)
class Config:
env_file = ".env"
case_sensitive = True
@lru_cache()
def get_settings() -> Settings:
"""
Get application settings from environment variables or .env file
"""
return Settings()
+27
View File
@@ -0,0 +1,27 @@
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import get_settings
settings = get_settings()
# Configure SQLAlchemy
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create base class for SQLAlchemy models
Base = declarative_base()
def get_db() -> Generator:
"""
Dependency for getting DB sessions
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime, timedelta
from typing import Any, Union
from jose import jwt
from passlib.context import CryptContext
from app.core.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
) -> str:
"""
Create a JWT access token for authentication
"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""
Verify a password against its hash
"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""
Hash a password
"""
return pwd_context.hash(password)
+78
View File
@@ -0,0 +1,78 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
import os
from app.api.api_v1.api import api_router
from app.core.config import get_settings
settings = get_settings()
def create_app() -> FastAPI:
"""Create and configure the FastAPI application"""
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
version="0.1.0",
)
# Set all CORS enabled origins
if settings.BACKEND_CORS_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_STR)
# Mount static files directory
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
return app
app = create_app()
# Initialize Jinja2 templates
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
templates = Jinja2Templates(directory=templates_dir)
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Root endpoint that serves the main HTML page"""
return templates.TemplateResponse(
"index.html",
{"request": request, "app_name": settings.PROJECT_NAME}
)
# Frontend routes that should return the SPA template
@app.get("/dashboard", response_class=HTMLResponse)
@app.get("/login", response_class=HTMLResponse)
@app.get("/setup", response_class=HTMLResponse)
@app.get("/domains", response_class=HTMLResponse)
@app.get("/reports", response_class=HTMLResponse)
@app.get("/settings", response_class=HTMLResponse)
async def serve_spa(request: Request):
"""Serve the SPA for frontend routes"""
return templates.TemplateResponse(
"index.html",
{"request": request, "app_name": settings.PROJECT_NAME}
)
# Fallback for other routes (404 handling)
@app.get("/{path:path}", response_class=HTMLResponse)
async def catch_all(request: Request, path: str):
"""Catch-all route that serves the main HTML page for client-side routing"""
return templates.TemplateResponse(
"index.html",
{"request": request, "app_name": settings.PROJECT_NAME}
)
+58
View File
@@ -0,0 +1,58 @@
from typing import List, Optional
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
class Domain(Base):
"""Domain model representing a monitored domain"""
__tablename__ = "domains"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(Text, nullable=True)
active = Column(Boolean, default=True)
# DMARC policy information
dmarc_policy = Column(String, nullable=True)
spf_record = Column(String, nullable=True)
dkim_selectors = Column(String, nullable=True) # Comma-separated list of DKIM selectors
# DNS verification status
verified = Column(Boolean, default=False)
verification_token = Column(String, nullable=True)
# Date fields
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
reports = relationship("DMARCReport", back_populates="domain", cascade="all, delete-orphan")
user_domains = relationship("UserDomain", back_populates="domain", cascade="all, delete-orphan")
def __repr__(self):
return f"<Domain {self.name}>"
class UserDomain(Base):
"""Association table for users and domains"""
__tablename__ = "user_domains"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
domain_id = Column(Integer, ForeignKey("domains.id"), nullable=False)
# Access level (admin, viewer, etc)
role = Column(String, default="viewer", nullable=False)
# Date fields
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="user_domains")
domain = relationship("Domain", back_populates="user_domains")
+73
View File
@@ -0,0 +1,73 @@
from datetime import datetime
from typing import List, Optional
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
class DMARCReport(Base):
"""DMARC Aggregate Report model"""
__tablename__ = "dmarc_reports"
id = Column(Integer, primary_key=True, index=True)
domain_id = Column(Integer, ForeignKey("domains.id"), nullable=False)
# Report metadata
report_id = Column(String, index=True, nullable=False)
org_name = Column(String, nullable=False)
begin_date = Column(Integer, nullable=False) # Unix timestamp
end_date = Column(Integer, nullable=False) # Unix timestamp
source_email = Column(String, nullable=True)
# Policy information
policy = Column(String, nullable=True) # none, quarantine, reject
subdomain_policy = Column(String, nullable=True)
adkim = Column(String(1), nullable=True) # r (relaxed) or s (strict)
aspf = Column(String(1), nullable=True) # r (relaxed) or s (strict)
percentage = Column(Integer, nullable=True)
# Processing metadata
processed_at = Column(DateTime, default=datetime.utcnow)
raw_data = Column(Text, nullable=True) # Original XML content (optional)
# Relationships
domain = relationship("Domain", back_populates="reports")
records = relationship("ReportRecord", back_populates="report", cascade="all, delete-orphan")
def __repr__(self):
return f"<DMARCReport {self.report_id} for {self.domain_id}>"
class ReportRecord(Base):
"""Individual record within a DMARC report"""
__tablename__ = "report_records"
id = Column(Integer, primary_key=True, index=True)
report_id = Column(Integer, ForeignKey("dmarc_reports.id"), nullable=False)
# Source information
source_ip = Column(String, nullable=False, index=True)
count = Column(Integer, nullable=False, default=0)
# Policy evaluation
disposition = Column(String, nullable=False) # none, quarantine, reject
dkim = Column(String, nullable=True) # pass, fail
spf = Column(String, nullable=True) # pass, fail
# Identifiers
header_from = Column(String, nullable=True)
envelope_from = Column(String, nullable=True)
# Authentication details (optional JSON fields)
dkim_auth_details = Column(Text, nullable=True) # JSON array of DKIM results
spf_auth_details = Column(Text, nullable=True) # JSON array of SPF results
# Relationships
report = relationship("DMARCReport", back_populates="records")
def __repr__(self):
return f"<ReportRecord {self.id} ({self.source_ip})>"
+25
View File
@@ -0,0 +1,25 @@
from typing import List, Optional
from sqlalchemy import Boolean, Column, Integer, String
from sqlalchemy.orm import relationship
from app.core.database import Base
class User(Base):
"""User model"""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
is_verified = Column(Boolean, default=False)
# Additional fields
full_name = Column(String, nullable=True)
organization = Column(String, nullable=True)
# Relationships
user_domains = relationship("UserDomain", back_populates="user", cascade="all, delete-orphan")
+183
View File
@@ -0,0 +1,183 @@
import os
import zipfile
import gzip
import io
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
import xml.etree.ElementTree as ET
import logging
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class DMARCParser:
"""
Parser for DMARC Aggregate Reports (XML format)
"""
@staticmethod
def parse_file(file_content: bytes, filename: str) -> Dict[str, Any]:
"""
Parse a DMARC report file (XML, zip, or gzip) into a dictionary
Args:
file_content: The binary content of the file
filename: The name of the file (used to determine type)
Returns:
Dict containing the parsed report data
"""
# Determine file type and extract XML content
xml_content = DMARCParser._extract_xml_content(file_content, filename)
if not xml_content:
raise ValueError("Could not extract XML content from file")
# Parse the XML content
return DMARCParser._parse_xml(xml_content)
@staticmethod
def _extract_xml_content(file_content: bytes, filename: str) -> Optional[bytes]:
"""
Extract XML content from various file formats (ZIP, GZIP, or plain XML)
"""
# Try to handle as ZIP file
if filename.lower().endswith('.zip'):
try:
with zipfile.ZipFile(io.BytesIO(file_content)) as z:
# Find the first XML file in the archive
for file_info in z.infolist():
if file_info.filename.lower().endswith('.xml'):
return z.read(file_info.filename)
except zipfile.BadZipFile:
pass
# Try to handle as GZIP file
if filename.lower().endswith('.gz') or filename.lower().endswith('.gzip'):
try:
return gzip.decompress(file_content)
except gzip.BadGzipFile:
pass
# Assume it's plain XML
if filename.lower().endswith('.xml'):
return file_content
return None
@staticmethod
def _parse_xml(xml_content: bytes) -> Dict[str, Any]:
"""
Parse DMARC XML content according to RFC 7489
"""
try:
root = ET.fromstring(xml_content)
report = {}
# Parse report metadata
metadata = root.find("report_metadata")
if metadata is not None:
report["report_id"] = metadata.findtext("report_id", "")
report["org_name"] = metadata.findtext("org_name", "")
report["email"] = metadata.findtext("email", "")
# Parse date range
date_range = metadata.find("date_range")
if date_range is not None:
begin_ts = int(date_range.findtext("begin", 0))
end_ts = int(date_range.findtext("end", 0))
report["begin_date"] = datetime.fromtimestamp(begin_ts).isoformat()
report["end_date"] = datetime.fromtimestamp(end_ts).isoformat()
report["begin_timestamp"] = begin_ts
report["end_timestamp"] = end_ts
# Parse policy published
policy = root.find("policy_published")
if policy is not None:
report["domain"] = policy.findtext("domain", "")
report["policy"] = {
"p": policy.findtext("p", "none"),
"sp": policy.findtext("sp", ""),
"pct": policy.findtext("pct", "100"),
}
# Parse records
records = []
for record_elem in root.findall("record"):
record = {}
# Parse row
row = record_elem.find("row")
if row is not None:
record["source_ip"] = row.findtext("source_ip", "")
record["count"] = int(row.findtext("count", 0))
policy_evaluated = row.find("policy_evaluated")
if policy_evaluated is not None:
record["disposition"] = policy_evaluated.findtext("disposition", "none")
record["dkim_result"] = policy_evaluated.findtext("dkim", "").lower()
record["spf_result"] = policy_evaluated.findtext("spf", "").lower()
# Parse identifiers
identifiers = record_elem.find("identifiers")
if identifiers is not None:
record["header_from"] = identifiers.findtext("header_from", "")
# Parse auth results
auth_results = record_elem.find("auth_results")
if auth_results is not None:
# SPF results
spf_entries = []
for spf in auth_results.findall("spf"):
spf_entries.append({
"domain": spf.findtext("domain", ""),
"result": spf.findtext("result", "").lower()
})
if spf_entries:
record["spf"] = spf_entries
# DKIM results
dkim_entries = []
for dkim in auth_results.findall("dkim"):
dkim_entries.append({
"domain": dkim.findtext("domain", ""),
"result": dkim.findtext("result", "").lower(),
"selector": dkim.findtext("selector", "")
})
if dkim_entries:
record["dkim"] = dkim_entries
records.append(record)
report["records"] = records
# Calculate summary stats
total_count = sum(r["count"] for r in records)
# Count records that pass either SPF or DKIM (or both)
passed_count = sum(r["count"] for r in records
if r.get("spf_result") == "pass" or r.get("dkim_result") == "pass")
failed_count = total_count - passed_count
# Log parse results for debugging
logger.info(f"Parsed DMARC report for domain: {report.get('domain')}")
logger.info(f"Found {len(records)} record entries with {total_count} total messages")
logger.info(f"Messages passed: {passed_count}, failed: {failed_count}")
if len(records) > 0:
# Log the first record for debugging
logger.info(f"Sample record - SPF: {records[0].get('spf_result')}, DKIM: {records[0].get('dkim_result')}")
report["summary"] = {
"total_count": total_count,
"passed_count": passed_count,
"failed_count": failed_count,
"pass_rate": (passed_count / total_count * 100) if total_count > 0 else 0
}
return report
except Exception as e:
logger.error(f"Error parsing DMARC XML: {str(e)}")
raise ValueError(f"Error parsing DMARC XML: {str(e)}")
+109
View File
@@ -0,0 +1,109 @@
from typing import Dict, List, Any
import threading
class ReportStore:
"""
In-memory store for DMARC reports
(for Milestone 1, will be replaced with database in Milestone 3)
"""
_instance = None
_lock = threading.Lock()
@classmethod
def get_instance(cls) -> 'ReportStore':
"""
Get singleton instance of the report store
"""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = ReportStore()
return cls._instance
def __init__(self):
"""
Initialize empty report store
"""
# Domain -> list of reports
self.domain_reports: Dict[str, List[Dict[str, Any]]] = {}
# Domain -> summary stats
self.domain_summary: Dict[str, Dict[str, Any]] = {}
def add_report(self, report: Dict[str, Any]) -> None:
"""
Add a new report to the store
Args:
report: Parsed DMARC report from DMARCParser
"""
domain = report.get("domain", "unknown")
# Initialize data structures if this is a new domain
if domain not in self.domain_reports:
self.domain_reports[domain] = []
self.domain_summary[domain] = {
"total_count": 0,
"passed_count": 0,
"failed_count": 0,
"reports_processed": 0,
}
# Add the new report
self.domain_reports[domain].append(report)
# Update summary stats for this domain
summary = report.get("summary", {})
self.domain_summary[domain]["total_count"] += summary.get("total_count", 0)
self.domain_summary[domain]["passed_count"] += summary.get("passed_count", 0)
self.domain_summary[domain]["failed_count"] += summary.get("failed_count", 0)
self.domain_summary[domain]["reports_processed"] += 1
# Calculate compliance rate (percentage of passing emails)
if self.domain_summary[domain]["total_count"] > 0:
pass_rate = (
self.domain_summary[domain]["passed_count"] /
self.domain_summary[domain]["total_count"] * 100
)
self.domain_summary[domain]["compliance_rate"] = round(pass_rate, 1)
else:
self.domain_summary[domain]["compliance_rate"] = 0
def get_domains(self) -> List[str]:
"""
Get list of all domains with reports
"""
return list(self.domain_reports.keys())
def get_domain_summary(self, domain: str) -> Dict[str, Any]:
"""
Get summary statistics for a domain
Args:
domain: Domain name
Returns:
Dictionary with summary stats or empty dict if domain not found
"""
return self.domain_summary.get(domain, {})
def get_all_domain_summaries(self) -> Dict[str, Dict[str, Any]]:
"""
Get summary statistics for all domains
Returns:
Dictionary mapping domain names to their summary stats
"""
return self.domain_summary
def get_domain_reports(self, domain: str) -> List[Dict[str, Any]]:
"""
Get all reports for a domain
Args:
domain: Domain name
Returns:
List of reports or empty list if domain not found
"""
return self.domain_reports.get(domain, [])
+161
View File
@@ -0,0 +1,161 @@
/**
* DMARQ Integrated Frontend Styles
*/
/* Setup Wizard Styles */
.setup-progress {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
position: relative;
}
.setup-step {
position: relative;
padding: 0.5rem 1rem;
background-color: #f3f4f6;
border-radius: 0.25rem;
font-weight: 500;
z-index: 1;
}
.setup-step.active {
background-color: #3b82f6;
color: white;
}
.setup-step.completed {
background-color: #10b981;
color: white;
}
.setup-progress:after {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 2px;
background-color: #e5e7eb;
z-index: 0;
}
/* Form Styles */
input, select, textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
button {
background-color: #3b82f6;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 500;
}
button:hover {
background-color: #2563eb;
}
button:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
/* Stats and Dashboard Styles */
.stat {
font-size: 2rem;
font-weight: 700;
color: #3b82f6;
}
.dashboard-stats .card {
text-align: center;
}
/* Additional Utility Classes */
.text-center {
text-align: center;
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.mt-4 {
margin-top: 1rem;
}
.mb-4 {
margin-bottom: 1rem;
}
/* Navigation Styles */
.sidebar ul {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar ul li {
margin-bottom: 0.5rem;
}
.sidebar ul li a {
display: block;
padding: 0.5rem;
border-radius: 0.25rem;
text-decoration: none;
color: var(--text-color);
}
.sidebar ul li a:hover {
background-color: #f3f4f6;
}
.sidebar ul li a.active {
background-color: #3b82f6;
color: white;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.sidebar {
width: 100%;
height: auto;
position: static;
border-right: none;
border-bottom: 1px solid #e2e8f0;
margin-bottom: 1rem;
}
.main-content {
margin-left: 0;
}
.dashboard-stats {
grid-template-columns: 1fr;
}
}
+199
View File
@@ -0,0 +1,199 @@
/**
* DMARQ Frontend Application
* Vanilla JS implementation replacing the React frontend
*/
// Global state management
const appState = {
isAuthenticated: false,
isSetupComplete: null,
currentPage: null,
user: null,
};
// API utility functions
const api = {
baseUrl: '/api/v1',
async request(endpoint, options = {}) {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const config = {
...options,
headers,
};
const response = await fetch(`${this.baseUrl}${endpoint}`, config);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'An error occurred' }));
throw new Error(error.detail || 'API request failed');
}
return response.json();
},
// Authentication endpoints
auth: {
async login(username, password) {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
const response = await fetch(`${api.baseUrl}/auth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData,
});
if (!response.ok) {
throw new Error('Login failed');
}
const data = await response.json();
localStorage.setItem('auth_token', data.access_token);
return data;
},
logout() {
localStorage.removeItem('auth_token');
appState.isAuthenticated = false;
appState.user = null;
router.navigate('/login');
}
},
// System endpoints
system: {
async health() {
return api.request('/health');
}
},
// Domain endpoints
domains: {
async getAll() {
return api.request('/domains');
},
async getById(id) {
return api.request(`/domains/${id}`);
}
},
// Reports endpoints
reports: {
async getAll() {
return api.request('/reports');
},
async getById(id) {
return api.request(`/reports/${id}`);
}
}
};
// Simple router implementation
const router = {
routes: {
'/': () => handleHome(),
'/login': () => renderLogin(),
'/dashboard': () => renderDashboard(),
'/setup': () => renderSetup()
},
init() {
// Initial route handling
window.addEventListener('popstate', () => this.handleRouteChange());
// Handle clicks on links to use client-side routing
document.addEventListener('click', (e) => {
if (e.target.matches('a[data-route]')) {
e.preventDefault();
this.navigate(e.target.getAttribute('href'));
}
});
// Initial route
this.handleRouteChange();
},
handleRouteChange() {
const path = window.location.pathname;
const route = this.routes[path];
if (route) {
route();
appState.currentPage = path;
} else {
this.navigate('/');
}
},
navigate(path) {
window.history.pushState(null, null, path);
this.handleRouteChange();
}
};
// Handle initial app loading
async function initApp() {
try {
// Check if user is authenticated
const token = localStorage.getItem('auth_token');
appState.isAuthenticated = !!token;
// Check system setup status
const healthData = await api.system.health().catch(() => ({ is_setup_complete: false }));
appState.isSetupComplete = healthData.is_setup_complete;
// Determine which page to show
handleHome();
} catch (error) {
console.error('Error initializing app:', error);
showError('Failed to initialize the application');
} finally {
// Hide loading indicator
document.getElementById('loading').classList.add('hidden');
}
}
// Handle the home route based on app state
function handleHome() {
if (!appState.isSetupComplete) {
router.navigate('/setup');
} else if (!appState.isAuthenticated) {
router.navigate('/login');
} else {
router.navigate('/dashboard');
}
}
// Helper function to show errors
function showError(message) {
const errorEl = document.createElement('div');
errorEl.className = 'error-message';
errorEl.textContent = message;
errorEl.style.cssText = 'background-color: #fee2e2; color: #b91c1c; padding: 1rem; border-radius: 0.25rem; margin-bottom: 1rem;';
const app = document.getElementById('app');
app.prepend(errorEl);
setTimeout(() => {
errorEl.remove();
}, 5000);
}
// Initialize the app when DOM is fully loaded
document.addEventListener('DOMContentLoaded', initApp);
document.addEventListener('DOMContentLoaded', () => router.init());
+245
View File
@@ -0,0 +1,245 @@
/**
* Dashboard page functionality
*/
async function renderDashboard() {
// Verify authentication
if (!appState.isAuthenticated) {
router.navigate('/login');
return;
}
const appElement = document.getElementById('app');
// Create dashboard layout
appElement.innerHTML = `
<div class="navbar">
<div class="logo">DMARQ</div>
<div class="user-menu">
<button id="logout-button">Logout</button>
</div>
</div>
<div class="sidebar">
<ul>
<li><a href="/dashboard" data-route>Dashboard</a></li>
<li><a href="/domains" data-route>Domains</a></li>
<li><a href="/reports" data-route>Reports</a></li>
<li><a href="/settings" data-route>Settings</a></li>
</ul>
</div>
<div class="main-content">
<h1>Dashboard</h1>
<div id="loading-dashboard">Loading dashboard data...</div>
<div id="dashboard-content" class="hidden">
<div class="dashboard-stats">
<div class="card">
<h3>Total Domains</h3>
<div id="total-domains" class="stat">-</div>
</div>
<div class="card">
<h3>Total Reports</h3>
<div id="total-reports" class="stat">-</div>
</div>
<div class="card">
<h3>Compliance Rate</h3>
<div id="compliance-rate" class="stat">-</div>
</div>
</div>
<div class="card">
<h2>Compliance Overview</h2>
<div class="chart-container">
<canvas id="compliance-chart"></canvas>
</div>
</div>
<div class="card">
<h2>Recent Reports</h2>
<div id="recent-reports">
<table>
<thead>
<tr>
<th>Domain</th>
<th>Date</th>
<th>Status</th>
</tr>
</thead>
<tbody id="reports-table-body">
</tbody>
</table>
</div>
</div>
</div>
</div>
`;
// Add event listener for logout
document.getElementById('logout-button').addEventListener('click', () => {
api.auth.logout();
});
try {
// Load dashboard data
await loadDashboardData();
} catch (error) {
console.error('Error loading dashboard data:', error);
showError('Failed to load dashboard data');
}
}
async function loadDashboardData() {
try {
// Fetch domains and reports data
const [domainsResponse, reportsResponse] = await Promise.all([
api.domains.getAll(),
api.reports.getAll()
]);
const domains = domainsResponse || [];
const reports = reportsResponse || [];
// Update stats
document.getElementById('total-domains').textContent = domains.length;
document.getElementById('total-reports').textContent = reports.length;
// Calculate compliance rate
const compliantReports = reports.filter(report => report.is_compliant);
const complianceRate = reports.length > 0
? Math.round((compliantReports.length / reports.length) * 100)
: 0;
document.getElementById('compliance-rate').textContent = `${complianceRate}%`;
// Render compliance chart
renderComplianceChart(reports);
// Render recent reports table
renderRecentReports(reports, domains);
// Hide loading, show content
document.getElementById('loading-dashboard').classList.add('hidden');
document.getElementById('dashboard-content').classList.remove('hidden');
} catch (error) {
throw new Error('Failed to load dashboard data');
}
}
function renderComplianceChart(reports) {
if (!reports || reports.length === 0) return;
const canvas = document.getElementById('compliance-chart');
if (!canvas) return;
// Prepare data
const last6Months = [];
const currentDate = new Date();
for (let i = 5; i >= 0; i--) {
const date = new Date(currentDate);
date.setMonth(currentDate.getMonth() - i);
const monthName = date.toLocaleString('default', { month: 'short' });
last6Months.push({
month: monthName,
year: date.getFullYear(),
reports: [],
startDate: new Date(date.getFullYear(), date.getMonth(), 1),
endDate: new Date(date.getFullYear(), date.getMonth() + 1, 0)
});
}
// Group reports by month
reports.forEach(report => {
const reportDate = new Date(report.report_date);
const monthData = last6Months.find(monthInfo =>
reportDate >= monthInfo.startDate && reportDate <= monthInfo.endDate
);
if (monthData) {
monthData.reports.push(report);
}
});
// Calculate compliance rates by month
const complianceData = last6Months.map(monthInfo => {
if (monthInfo.reports.length === 0) return 0;
const compliantCount = monthInfo.reports.filter(report => report.is_compliant).length;
return Math.round((compliantCount / monthInfo.reports.length) * 100);
});
const labels = last6Months.map(monthInfo => `${monthInfo.month} ${monthInfo.year}`);
// Create chart
new Chart(canvas, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Compliance Rate (%)',
data: complianceData,
backgroundColor: '#3b82f6',
borderColor: '#2563eb',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100,
title: {
display: true,
text: 'Compliance Rate (%)'
}
}
}
}
});
}
function renderRecentReports(reports, domains) {
if (!reports || reports.length === 0) return;
const tableBody = document.getElementById('reports-table-body');
if (!tableBody) return;
// Sort reports by date (newest first) and take last 10
const sortedReports = [...reports]
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date))
.slice(0, 10);
// Create a lookup map for domains
const domainMap = new Map();
domains.forEach(domain => {
domainMap.set(domain.id, domain.domain_name);
});
// Add rows to the table
sortedReports.forEach(report => {
const row = document.createElement('tr');
// Format date
const reportDate = new Date(report.report_date);
const formattedDate = reportDate.toLocaleDateString();
// Get domain name
const domainName = domainMap.get(report.domain_id) || 'Unknown';
row.innerHTML = `
<td>${domainName}</td>
<td>${formattedDate}</td>
<td>${report.is_compliant ?
'<span style="color: green;">Compliant</span>' :
'<span style="color: red;">Non-compliant</span>'
}</td>
`;
tableBody.appendChild(row);
});
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Login page functionality
*/
function renderLogin() {
const appElement = document.getElementById('app');
// Create login form HTML
appElement.innerHTML = `
<div class="auth-container">
<div class="card">
<h1>Login to DMARQ</h1>
<form id="login-form">
<div>
<label for="username">Username</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="password" required>
</div>
<div id="login-error" class="hidden" style="color: red; margin-top: 10px;"></div>
<div>
<button type="submit" id="login-button">Login</button>
</div>
</form>
</div>
</div>
`;
// Add event listener to the login form
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const loginButton = document.getElementById('login-button');
const loginError = document.getElementById('login-error');
// Reset UI state
loginError.classList.add('hidden');
loginButton.disabled = true;
loginButton.textContent = 'Logging in...';
try {
await api.auth.login(username, password);
appState.isAuthenticated = true;
router.navigate('/dashboard');
} catch (error) {
loginError.textContent = 'Invalid username or password';
loginError.classList.remove('hidden');
} finally {
loginButton.disabled = false;
loginButton.textContent = 'Login';
}
});
}
+286
View File
@@ -0,0 +1,286 @@
/**
* Setup Wizard functionality
*/
function renderSetup() {
const appElement = document.getElementById('app');
// Create setup wizard layout
appElement.innerHTML = `
<div class="auth-container" style="max-width: 600px;">
<div class="card">
<h1>DMARQ Setup Wizard</h1>
<div class="setup-progress">
<div class="setup-step active" id="step-1">1. Admin Account</div>
<div class="setup-step" id="step-2">2. System Configuration</div>
<div class="setup-step" id="step-3">3. Email Configuration</div>
</div>
<div id="setup-content">
<!-- Step 1: Admin Account -->
<div id="setup-step-1" class="setup-form">
<h2>Create Admin Account</h2>
<form id="admin-setup-form">
<div>
<label for="admin-email">Email</label>
<input type="email" id="admin-email" name="admin-email" required>
</div>
<div>
<label for="admin-username">Username</label>
<input type="text" id="admin-username" name="admin-username" required>
</div>
<div>
<label for="admin-password">Password</label>
<input type="password" id="admin-password" name="admin-password" required>
</div>
<div>
<label for="admin-password-confirm">Confirm Password</label>
<input type="password" id="admin-password-confirm" name="admin-password-confirm" required>
</div>
<div id="admin-error" class="hidden" style="color: red; margin-top: 10px;"></div>
<div style="margin-top: 20px;">
<button type="submit" id="admin-next-button">Next</button>
</div>
</form>
</div>
<!-- Step 2: System Configuration -->
<div id="setup-step-2" class="setup-form hidden">
<h2>System Configuration</h2>
<form id="system-setup-form">
<div>
<label for="app-name">Application Name</label>
<input type="text" id="app-name" name="app-name" value="DMARQ" required>
</div>
<div>
<label for="base-url">Base URL</label>
<input type="url" id="base-url" name="base-url" placeholder="https://your-dmarq-instance.com" required>
</div>
<div>
<label>
<input type="checkbox" id="enable-cloudflare" name="enable-cloudflare">
Enable Cloudflare Integration
</label>
</div>
<div id="cloudflare-settings" class="hidden">
<div>
<label for="cloudflare-token">Cloudflare API Token</label>
<input type="password" id="cloudflare-token" name="cloudflare-token">
</div>
<div>
<label for="cloudflare-zone">Cloudflare Zone ID</label>
<input type="text" id="cloudflare-zone" name="cloudflare-zone">
</div>
</div>
<div id="system-error" class="hidden" style="color: red; margin-top: 10px;"></div>
<div style="margin-top: 20px; display: flex; justify-content: space-between;">
<button type="button" id="system-prev-button">Previous</button>
<button type="submit" id="system-next-button">Next</button>
</div>
</form>
</div>
<!-- Step 3: Email Configuration -->
<div id="setup-step-3" class="setup-form hidden">
<h2>Email Configuration</h2>
<form id="email-setup-form">
<div>
<label for="imap-server">IMAP Server</label>
<input type="text" id="imap-server" name="imap-server" required>
</div>
<div>
<label for="imap-port">IMAP Port</label>
<input type="number" id="imap-port" name="imap-port" value="993" required>
</div>
<div>
<label for="imap-username">IMAP Username</label>
<input type="text" id="imap-username" name="imap-username" required>
</div>
<div>
<label for="imap-password">IMAP Password</label>
<input type="password" id="imap-password" name="imap-password" required>
</div>
<div>
<button type="button" id="test-imap-button">Test Connection</button>
<span id="test-imap-result"></span>
</div>
<div id="email-error" class="hidden" style="color: red; margin-top: 10px;"></div>
<div style="margin-top: 20px; display: flex; justify-content: space-between;">
<button type="button" id="email-prev-button">Previous</button>
<button type="submit" id="email-finish-button">Finish Setup</button>
</div>
</form>
</div>
</div>
</div>
</div>
`;
// Add event listeners and setup functionality for the wizard
setupWizardEventListeners();
}
function setupWizardEventListeners() {
// Step 1: Admin Account setup
const adminForm = document.getElementById('admin-setup-form');
if (adminForm) {
adminForm.addEventListener('submit', (e) => {
e.preventDefault();
const email = document.getElementById('admin-email').value;
const username = document.getElementById('admin-username').value;
const password = document.getElementById('admin-password').value;
const confirmPassword = document.getElementById('admin-password-confirm').value;
const errorElement = document.getElementById('admin-error');
// Simple validation
if (password !== confirmPassword) {
errorElement.textContent = 'Passwords do not match';
errorElement.classList.remove('hidden');
return;
}
// Store values (in a real app, you'd save these to the server)
localStorage.setItem('setup_admin_email', email);
localStorage.setItem('setup_admin_username', username);
// Move to step 2
goToStep(2);
});
}
// Step 2: System Configuration
const systemForm = document.getElementById('system-setup-form');
if (systemForm) {
// Toggle Cloudflare settings visibility
const enableCloudflare = document.getElementById('enable-cloudflare');
const cloudflareSettings = document.getElementById('cloudflare-settings');
enableCloudflare.addEventListener('change', () => {
if (enableCloudflare.checked) {
cloudflareSettings.classList.remove('hidden');
} else {
cloudflareSettings.classList.add('hidden');
}
});
// Previous button
document.getElementById('system-prev-button').addEventListener('click', () => {
goToStep(1);
});
// Next button
systemForm.addEventListener('submit', (e) => {
e.preventDefault();
// Store values
const appName = document.getElementById('app-name').value;
const baseUrl = document.getElementById('base-url').value;
localStorage.setItem('setup_app_name', appName);
localStorage.setItem('setup_base_url', baseUrl);
if (enableCloudflare.checked) {
const cloudflareToken = document.getElementById('cloudflare-token').value;
const cloudflareZone = document.getElementById('cloudflare-zone').value;
localStorage.setItem('setup_cloudflare_enabled', 'true');
localStorage.setItem('setup_cloudflare_token', cloudflareToken);
localStorage.setItem('setup_cloudflare_zone', cloudflareZone);
}
// Move to step 3
goToStep(3);
});
}
// Step 3: Email Configuration
const emailForm = document.getElementById('email-setup-form');
if (emailForm) {
// Previous button
document.getElementById('email-prev-button').addEventListener('click', () => {
goToStep(2);
});
// Test IMAP connection
document.getElementById('test-imap-button').addEventListener('click', async () => {
const testButton = document.getElementById('test-imap-button');
const resultSpan = document.getElementById('test-imap-result');
testButton.disabled = true;
testButton.textContent = 'Testing...';
resultSpan.textContent = '';
try {
// In a real app, you'd make an API call to test the connection
// Here we'll just simulate it
await new Promise(resolve => setTimeout(resolve, 1500));
resultSpan.textContent = '✓ Connection successful';
resultSpan.style.color = 'green';
} catch (error) {
resultSpan.textContent = '✗ Connection failed';
resultSpan.style.color = 'red';
} finally {
testButton.disabled = false;
testButton.textContent = 'Test Connection';
}
});
// Finish setup
emailForm.addEventListener('submit', async (e) => {
e.preventDefault();
const imapServer = document.getElementById('imap-server').value;
const imapPort = document.getElementById('imap-port').value;
const imapUsername = document.getElementById('imap-username').value;
const imapPassword = document.getElementById('imap-password').value;
const finishButton = document.getElementById('email-finish-button');
const errorElement = document.getElementById('email-error');
finishButton.disabled = true;
finishButton.textContent = 'Completing Setup...';
errorElement.classList.add('hidden');
try {
// In a real app, you'd send all the setup data to the server
// For this example, we'll simulate the API call
await new Promise(resolve => setTimeout(resolve, 2000));
// Update app state
appState.isSetupComplete = true;
// Redirect to login
router.navigate('/login');
} catch (error) {
errorElement.textContent = 'Setup failed: ' + (error.message || 'Unknown error');
errorElement.classList.remove('hidden');
finishButton.disabled = false;
finishButton.textContent = 'Finish Setup';
}
});
}
}
function goToStep(stepNumber) {
// Hide all steps
document.querySelectorAll('.setup-form').forEach(form => {
form.classList.add('hidden');
});
// Show selected step
document.getElementById(`setup-step-${stepNumber}`).classList.remove('hidden');
// Update step indicators
document.querySelectorAll('.setup-step').forEach((step, index) => {
if (index + 1 === stepNumber) {
step.classList.add('active');
} else if (index + 1 < stepNumber) {
step.classList.add('completed');
step.classList.remove('active');
} else {
step.classList.remove('active', 'completed');
}
});
}
+866
View File
@@ -0,0 +1,866 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DMARQ - DMARC Monitoring</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome for icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css">
<!-- Inter font -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="/static/css/styles.css">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["Inter", "sans-serif"],
},
}
}
}
</script>
<style>
[x-cloak] { display: none !important; }
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 232 47% 49%;
--primary-foreground: 0 0% 98%;
--secondary: 187 100% 42%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 232 47% 49%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 232 47% 49%;
--primary-foreground: 0 0% 98%;
--secondary: 187 100% 42%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 232 47% 49%;
}
</style>
</head>
<body class="min-h-screen bg-background font-sans antialiased">
<!-- Alpine.js for reactivity -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<div x-data="dashboardApp()">
<!-- Sidebar -->
<div class="fixed inset-y-0 left-0 z-20 w-64 bg-card border-r border-border shadow-sm transform transition-transform duration-300 lg:translate-x-0"
:class="{'translate-x-0': sidebarOpen, '-translate-x-full': !sidebarOpen}">
<!-- Logo -->
<div class="flex items-center justify-between p-4 border-b border-border">
<div class="flex items-center space-x-2">
<svg class="w-8 h-8 text-primary" fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14h-2V9h2v8zm4 0h-2v-4h2v4zm0-6h-2V9h2v2zm-8 2H6v4h2v-4zm0-4H6v2h2V9z"/>
</svg>
<div class="text-xl font-bold text-foreground">DMARQ</div>
</div>
<button @click="sidebarOpen = false" type="button" class="p-1 text-muted-foreground rounded-md lg:hidden hover:bg-accent hover:text-accent-foreground">
<span class="sr-only">Close sidebar</span>
<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 p-2 overflow-y-auto">
<ul class="space-y-1">
<li>
<a href="#" @click.prevent="activeTab = 'dashboard'"
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
:class="activeTab === 'dashboard' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
<i class="fas fa-tachometer-alt w-4 h-4 mr-2"></i>
<span>Dashboard</span>
</a>
</li>
<li>
<a href="#" @click.prevent="activeTab = 'upload'"
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
:class="activeTab === 'upload' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
<i class="fas fa-upload w-4 h-4 mr-2"></i>
<span>Upload Reports</span>
</a>
</li>
<li>
<a href="#" @click.prevent="activeTab = 'domains'"
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
:class="activeTab === 'domains' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
<i class="fas fa-globe w-4 h-4 mr-2"></i>
<span>Domains</span>
</a>
</li>
<li>
<a href="#" @click.prevent="activeTab = 'reports'"
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
:class="activeTab === 'reports' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
<i class="fas fa-chart-bar w-4 h-4 mr-2"></i>
<span>Reports</span>
</a>
</li>
<li>
<a href="#" @click.prevent="activeTab = 'settings'"
class="flex items-center w-full px-3 py-2 transition-colors rounded-md text-sm font-medium"
:class="activeTab === 'settings' ? 'bg-primary text-primary-foreground' : 'text-foreground hover:bg-accent hover:text-accent-foreground'">
<i class="fas fa-cog w-4 h-4 mr-2"></i>
<span>Settings</span>
</a>
</li>
</ul>
</nav>
<div class="p-4 text-xs text-center text-muted-foreground">
<div>DMARQ v0.1.0</div>
<div>DMARC Monitoring Platform</div>
</div>
</div>
<!-- Main Content -->
<div class="lg:pl-64">
<!-- Top Navigation -->
<header class="sticky top-0 z-10 w-full bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b border-border">
<div class="flex items-center justify-between h-14 px-4">
<!-- Mobile menu button -->
<div class="flex items-center lg:hidden">
<button type="button" @click="sidebarOpen = true" class="p-2 text-muted-foreground rounded-md hover:bg-accent hover:text-accent-foreground">
<span class="sr-only">Open sidebar</span>
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
<!-- Page title -->
<div class="flex-1 px-2 mx-2">
<h1 class="text-lg font-semibold" x-text="getActiveTabTitle()"></h1>
</div>
<!-- Theme toggle and user profile -->
<div class="flex items-center space-x-2">
<button @click="toggleTheme()" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring h-9 w-9 bg-transparent hover:bg-accent hover:text-accent-foreground">
<svg x-show="!isDarkMode" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
<svg x-show="isDarkMode" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sun">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="m4.93 4.93 1.41 1.41"></path>
<path d="m17.66 17.66 1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="m6.34 17.66-1.41 1.41"></path>
<path d="m19.07 4.93-1.41 1.41"></path>
</svg>
</button>
<button type="button" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-9 w-9">
<i class="fas fa-user-circle text-lg"></i>
</button>
</div>
</div>
</header>
<!-- Page Content -->
<main class="p-6">
<!-- Dashboard Tab -->
<div x-show="activeTab === 'dashboard'" x-transition>
<!-- No Data Message -->
<div x-show="!hasDomainData" class="flex flex-col items-center justify-center p-8 rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="flex flex-col items-center max-w-md text-center">
<div class="rounded-full p-4 bg-muted mb-4">
<i class="fas fa-chart-line text-4xl text-muted-foreground"></i>
</div>
<h3 class="text-lg font-semibold mb-2">No DMARC reports have been uploaded yet</h3>
<p class="text-muted-foreground mb-4">Upload a report to see statistics and gain insights into your domain's email authentication.</p>
<button @click="activeTab = 'upload'" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
<i class="fas fa-upload mr-2"></i> Upload Report
</button>
</div>
</div>
<!-- Dashboard Content (when data is available) -->
<div x-show="hasDomainData">
<!-- Stats Overview -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total Domains -->
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
<h3 class="tracking-tight text-sm font-medium">Total Domains</h3>
<div class="p-2 bg-primary/10 rounded-full">
<i class="fas fa-globe text-primary"></i>
</div>
</div>
<div class="p-6 pt-0">
<div id="total-domains" class="text-2xl font-bold">0</div>
<p class="text-xs text-muted-foreground">Active domains being monitored</p>
</div>
</div>
<!-- Total Emails -->
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
<h3 class="tracking-tight text-sm font-medium">Emails Analyzed</h3>
<div class="p-2 bg-blue-500/10 rounded-full">
<i class="fas fa-envelope text-blue-500"></i>
</div>
</div>
<div class="p-6 pt-0">
<div id="total-emails" class="text-2xl font-bold">0</div>
<p class="text-xs text-muted-foreground">Total emails processed</p>
</div>
</div>
<!-- Pass Rate -->
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
<h3 class="tracking-tight text-sm font-medium">Pass Rate</h3>
<div class="p-2 bg-green-500/10 rounded-full">
<i class="fas fa-check-circle text-green-500"></i>
</div>
</div>
<div class="p-6 pt-0">
<div id="overall-pass-rate" class="text-2xl font-bold text-green-500">0%</div>
<p class="text-xs text-muted-foreground">Overall DMARC compliance</p>
</div>
</div>
<!-- Reports Processed -->
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="p-6 flex flex-row items-center justify-between space-y-0 pb-2">
<h3 class="tracking-tight text-sm font-medium">Reports Processed</h3>
<div class="p-2 bg-purple-500/10 rounded-full">
<i class="fas fa-file-alt text-purple-500"></i>
</div>
</div>
<div class="p-6 pt-0">
<div id="reports-processed" class="text-2xl font-bold">0</div>
<p class="text-xs text-muted-foreground">DMARC reports received</p>
</div>
</div>
</div>
<!-- Domain Compliance Table -->
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="flex flex-col space-y-1.5 p-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold leading-none tracking-tight">Domain Compliance</h3>
<button @click="fetchDomainSummary" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
<i class="fas fa-sync-alt mr-2"></i> Refresh
</button>
</div>
</div>
<div class="p-0">
<div class="w-full overflow-auto">
<table class="w-full caption-bottom text-sm">
<thead class="[&_tr]:border-b">
<tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Domain</th>
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Emails</th>
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Pass Rate</th>
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Failed</th>
<th class="h-10 px-4 text-left align-middle font-medium text-muted-foreground">Reports</th>
<th class="h-10 px-4 text-right align-middle font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody id="domains-table-body" class="[&_tr:last-child]:border-0">
<!-- Domains will be added here dynamically -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Upload Tab -->
<div x-show="activeTab === 'upload'" x-transition>
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="flex flex-col space-y-1.5 p-6">
<h3 class="text-lg font-semibold leading-none tracking-tight">Upload DMARC Reports</h3>
<p class="text-sm text-muted-foreground">Upload your DMARC aggregate report files (XML, ZIP, or GZIP)</p>
</div>
<div class="p-6 pt-0">
<form id="upload-form" enctype="multipart/form-data">
<div class="flex flex-col items-center justify-center border-2 border-dashed border-border rounded-md p-6 text-center hover:bg-muted/50 transition-colors cursor-pointer relative">
<div class="mb-3 text-muted-foreground">
<i class="fas fa-cloud-upload-alt text-4xl"></i>
</div>
<p class="mb-2 text-sm">
<span class="font-semibold">Click to upload</span> or drag and drop
</p>
<p class="text-xs text-muted-foreground">
XML, ZIP, or GZIP files only
</p>
<input id="report-file" type="file" name="file" accept=".xml,.zip,.gz,.gzip" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
</div>
<div id="file-selected" class="hidden mt-4 p-3 bg-muted rounded-md">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<i class="fas fa-file-code text-primary"></i>
<span id="file-name" class="text-sm font-medium"></span>
</div>
<button type="button" id="remove-file" class="text-muted-foreground hover:text-foreground">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
<i class="fas fa-upload mr-2"></i> Upload Report
</button>
</div>
</form>
<div id="upload-result" class="mt-4"></div>
</div>
</div>
</div>
<!-- Domains Tab -->
<div x-show="activeTab === 'domains'" x-transition>
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="flex flex-col space-y-1.5 p-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold leading-none tracking-tight">Domain Management</h3>
<button @click="fetchDomainSummary" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
<i class="fas fa-sync-alt mr-2"></i> Refresh
</button>
</div>
<p class="text-sm text-muted-foreground">View and manage domains with DMARC reports</p>
</div>
<div class="p-6 pt-0">
<p id="domains-message" class="text-center text-muted-foreground py-6">Loading domains...</p>
<div id="domains-list" class="hidden">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Domain cards will be added here dynamically -->
</div>
</div>
</div>
</div>
</div>
<!-- Domain Details Container -->
<div id="domain-details-container" x-show="activeTab === 'domain-details'" x-transition class="hidden">
<div class="mb-4">
<button id="back-to-summary" class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
<i class="fas fa-arrow-left mr-2"></i> Back to Summary
</button>
</div>
<div class="rounded-lg border border-border bg-card text-card-foreground shadow mb-6">
<div class="flex flex-col space-y-1.5 p-6">
<h3 id="domain-details-title" class="text-lg font-semibold leading-none tracking-tight">Domain Details</h3>
</div>
<div class="p-6 pt-0">
<div id="domain-details-content">
<!-- Domain details will be loaded here -->
</div>
</div>
</div>
</div>
<!-- Reports Tab -->
<div x-show="activeTab === 'reports'" x-transition>
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="flex flex-col space-y-1.5 p-6">
<h3 class="text-lg font-semibold leading-none tracking-tight">Reports Summary</h3>
<p class="text-sm text-muted-foreground">Advanced report visualization and analysis</p>
</div>
<div class="p-6 pt-0">
<div class="flex flex-col items-center justify-center p-8">
<div class="rounded-full p-3 bg-muted">
<i class="fas fa-chart-line text-2xl text-muted-foreground"></i>
</div>
<h3 class="mt-4 text-lg font-medium">Coming Soon</h3>
<p class="text-sm text-center text-muted-foreground mt-2 max-w-md">
Advanced report visualization and analysis features will be available in future milestones.
</p>
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div x-show="activeTab === 'settings'" x-transition>
<div class="rounded-lg border border-border bg-card text-card-foreground shadow">
<div class="flex flex-col space-y-1.5 p-6">
<h3 class="text-lg font-semibold leading-none tracking-tight">Settings</h3>
<p class="text-sm text-muted-foreground">User preferences and system configuration</p>
</div>
<div class="p-6 pt-0">
<div class="flex flex-col items-center justify-center p-8">
<div class="rounded-full p-3 bg-muted">
<i class="fas fa-cog text-2xl text-muted-foreground"></i>
</div>
<h3 class="mt-4 text-lg font-medium">Coming Soon</h3>
<p class="text-sm text-center text-muted-foreground mt-2 max-w-md">
User preferences and system configuration will be available in future milestones.
</p>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
function dashboardApp() {
return {
activeTab: 'dashboard',
sidebarOpen: false,
isDarkMode: true,
hasDomainData: false, // Will be set true when data is loaded
init() {
// Check if we have any domains with data
this.fetchDomainSummary();
// Check system preferences for dark mode
const savedTheme = localStorage.getItem('dmarq-theme');
if (savedTheme) {
this.isDarkMode = savedTheme === 'dark';
this.applyTheme();
}
// Watch for tab changes to refresh data
this.$watch('activeTab', (tab) => {
if (tab === 'dashboard' || tab === 'domains') {
this.fetchDomainSummary();
}
});
// Listen for custom event to refresh data
document.addEventListener('dmarq:refresh-data', () => {
this.fetchDomainSummary();
});
},
getActiveTabTitle() {
switch (this.activeTab) {
case 'dashboard': return 'Dashboard';
case 'upload': return 'Upload Reports';
case 'domains': return 'Domain Management';
case 'reports': return 'Reports Analysis';
case 'settings': return 'Settings';
case 'domain-details': return 'Domain Details';
default: return 'DMARQ';
}
},
toggleTheme() {
this.isDarkMode = !this.isDarkMode;
localStorage.setItem('dmarq-theme', this.isDarkMode ? 'dark' : 'light');
this.applyTheme();
},
applyTheme() {
if (this.isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
},
async fetchDomainSummary() {
try {
const response = await fetch('/api/v1/domains/summary');
const data = await response.json();
if (data && data.domains && data.domains.length > 0) {
this.hasDomainData = true;
this.updateDashboardStats(data);
this.populateDomainsTable(data.domains);
this.populateDomainsList(data.domains);
} else {
this.hasDomainData = false;
}
} catch (error) {
console.error('Error fetching domain summary:', error);
this.hasDomainData = false;
}
},
updateDashboardStats(data) {
if (!data) return;
const totalDomains = document.getElementById('total-domains');
const totalEmails = document.getElementById('total-emails');
const passRate = document.getElementById('overall-pass-rate');
const reportsProcessed = document.getElementById('reports-processed');
if (totalDomains) totalDomains.textContent = data.total_domains || 0;
if (totalEmails) totalEmails.textContent = data.total_emails || 0;
if (passRate) passRate.textContent = `${data.overall_pass_rate || 0}%`;
if (reportsProcessed) reportsProcessed.textContent = data.reports_processed || 0;
},
populateDomainsTable(domains) {
if (!domains || !domains.length) return;
const tableBody = document.getElementById('domains-table-body');
if (!tableBody) return;
tableBody.innerHTML = '';
domains.forEach(domain => {
const row = document.createElement('tr');
row.className = 'border-b transition-colors hover:bg-muted/50';
row.innerHTML = `
<td class="p-4 align-middle">
<div class="font-medium">${domain.domain_name}</div>
</td>
<td class="p-4 align-middle">${domain.total_emails || 0}</td>
<td class="p-4 align-middle">
<span class="inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/20 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">
${domain.pass_rate || 0}%
</span>
</td>
<td class="p-4 align-middle">${domain.failed_count || 0}</td>
<td class="p-4 align-middle">${domain.report_count || 0}</td>
<td class="p-4 align-middle text-right">
<button data-domain-id="${domain.id}" class="view-domain-details inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
<i class="fas fa-chart-bar mr-1"></i> Details
</button>
</td>
`;
tableBody.appendChild(row);
});
// Add event listeners to the view details buttons
document.querySelectorAll('.view-domain-details').forEach(button => {
button.addEventListener('click', () => {
const domainId = button.getAttribute('data-domain-id');
this.viewDomainDetails(domainId);
});
});
},
populateDomainsList(domains) {
if (!domains || !domains.length) return;
const domainsMessage = document.getElementById('domains-message');
const domainsList = document.getElementById('domains-list');
const domainsGrid = domainsList.querySelector('.grid');
if (domainsMessage && domainsList && domainsGrid) {
// Hide loading message, show domain list
domainsMessage.classList.add('hidden');
domainsList.classList.remove('hidden');
domainsGrid.innerHTML = '';
domains.forEach(domain => {
const card = document.createElement('div');
card.className = 'rounded-lg border border-border bg-card text-card-foreground shadow';
card.innerHTML = `
<div class="p-6">
<h4 class="text-lg font-semibold mb-2">${domain.domain_name}</h4>
<div class="space-y-2">
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">Emails:</span>
<span>${domain.total_emails || 0}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">Pass Rate:</span>
<span class="inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/20 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">
${domain.pass_rate || 0}%
</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">Failed:</span>
<span>${domain.failed_count || 0}</span>
</div>
<div class="flex justify-between items-center text-sm">
<span class="text-muted-foreground">Reports:</span>
<span>${domain.report_count || 0}</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-border flex justify-end">
<button data-domain-id="${domain.id}" class="view-domain-details inline-flex items-center justify-center rounded-md text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-8 px-3">
<i class="fas fa-chart-bar mr-1"></i> Details
</button>
</div>
</div>
`;
domainsGrid.appendChild(card);
});
// Add event listeners to the view details buttons
domainsList.querySelectorAll('.view-domain-details').forEach(button => {
button.addEventListener('click', () => {
const domainId = button.getAttribute('data-domain-id');
this.viewDomainDetails(domainId);
});
});
}
},
async viewDomainDetails(domainId) {
try {
const response = await fetch(`/api/v1/domains/${domainId}`);
const domain = await response.json();
// Update the domain details section
const detailsTitle = document.getElementById('domain-details-title');
const detailsContent = document.getElementById('domain-details-content');
if (detailsTitle) {
detailsTitle.textContent = `Domain: ${domain.domain_name}`;
}
if (detailsContent) {
// Populate the details content here
detailsContent.innerHTML = `
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<h4 class="text-sm font-medium mb-2">Domain Information</h4>
<dl class="grid grid-cols-[100px_1fr] gap-1">
<dt class="text-xs text-muted-foreground">Name:</dt>
<dd class="text-sm">${domain.domain_name}</dd>
<dt class="text-xs text-muted-foreground">Reports:</dt>
<dd class="text-sm">${domain.report_count || 0}</dd>
<dt class="text-xs text-muted-foreground">Emails:</dt>
<dd class="text-sm">${domain.total_emails || 0}</dd>
</dl>
</div>
<div>
<h4 class="text-sm font-medium mb-2">Compliance Summary</h4>
<dl class="grid grid-cols-[100px_1fr] gap-1">
<dt class="text-xs text-muted-foreground">Pass Rate:</dt>
<dd class="text-sm">${domain.pass_rate || 0}%</dd>
<dt class="text-xs text-muted-foreground">Failed:</dt>
<dd class="text-sm">${domain.failed_count || 0}</dd>
<dt class="text-xs text-muted-foreground">DKIM:</dt>
<dd class="text-sm">${domain.dkim_pass_count || 0} passed</dd>
<dt class="text-xs text-muted-foreground">SPF:</dt>
<dd class="text-sm">${domain.spf_pass_count || 0} passed</dd>
</dl>
</div>
</div>
<div class="mb-6">
<h4 class="text-sm font-medium mb-2">Recent Reports</h4>
<div class="border rounded-md overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-muted">
<tr>
<th class="p-2 text-left font-medium text-muted-foreground">Report ID</th>
<th class="p-2 text-left font-medium text-muted-foreground">Date</th>
<th class="p-2 text-left font-medium text-muted-foreground">Source</th>
<th class="p-2 text-left font-medium text-muted-foreground">Emails</th>
<th class="p-2 text-left font-medium text-muted-foreground">Pass Rate</th>
</tr>
</thead>
<tbody>
<tr class="border-t">
<td colspan="5" class="p-4 text-center text-muted-foreground">
Report details will be available in future milestones.
</td>
</tr>
</tbody>
</table>
</div>
</div>
`;
}
// Show the domain details container and hide others
this.activeTab = 'domain-details';
document.getElementById('domain-details-container').classList.remove('hidden');
} catch (error) {
console.error('Error fetching domain details:', error);
}
}
}
}
// Handle file upload preview
document.addEventListener('DOMContentLoaded', () => {
const fileInput = document.getElementById('report-file');
const fileSelected = document.getElementById('file-selected');
const fileName = document.getElementById('file-name');
const removeFile = document.getElementById('remove-file');
const uploadForm = document.getElementById('upload-form');
const uploadResult = document.getElementById('upload-result');
const backToSummary = document.getElementById('back-to-summary');
if (fileInput && fileSelected && fileName && removeFile) {
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
fileName.textContent = e.target.files[0].name;
fileSelected.classList.remove('hidden');
}
});
removeFile.addEventListener('click', () => {
fileInput.value = '';
fileSelected.classList.add('hidden');
});
}
if (uploadForm) {
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!fileInput.files.length) {
uploadResult.innerHTML = `
<div class="p-3 bg-destructive/20 text-destructive rounded-md">
Please select a file to upload
</div>
`;
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
uploadResult.innerHTML = `
<div class="p-3 bg-muted rounded-md flex items-center">
<i class="fas fa-spinner fa-spin mr-2"></i>
<span>Uploading and processing report...</span>
</div>
`;
try {
const response = await fetch('/api/v1/reports/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
uploadResult.innerHTML = `
<div class="p-3 bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-md">
<p><i class="fas fa-check-circle mr-2"></i> Report uploaded successfully</p>
<p class="text-xs mt-1">Processed ${data.processed_records || 0} records for domain ${data.domain || ''}</p>
</div>
`;
fileInput.value = '';
fileSelected.classList.add('hidden');
// Refresh the dashboard data after successful upload
// Use a custom event to communicate with the Alpine component
document.dispatchEvent(new CustomEvent('dmarq:refresh-data'));
} else {
throw new Error(data.detail || 'Upload failed');
}
} catch (error) {
uploadResult.innerHTML = `
<div class="p-3 bg-destructive/20 text-destructive rounded-md">
<i class="fas fa-exclamation-circle mr-2"></i>
${error.message || 'An error occurred during upload'}
</div>
`;
}
});
}
// Back button in domain details view
if (backToSummary) {
backToSummary.addEventListener('click', () => {
document.getElementById('domain-details-container').classList.add('hidden');
const app = document.querySelector('[x-data="dashboardApp()"]').__x.$data;
app.activeTab = 'domains';
});
}
});
</script>
</body>
</html>
+104
View File
@@ -0,0 +1,104 @@
import asyncio
import os
from typing import AsyncGenerator, Generator
import pytest
import pytest_asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
from httpx import AsyncClient
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import get_settings
from app.core.database import Base, get_db
from app.core.security import get_password_hash
from app.models.user import User
# Use in-memory SQLite database for tests
TEST_DATABASE_URL = "sqlite:///./test.db"
@pytest.fixture(scope="session")
def event_loop():
"""Create an instance of the default event loop for each test case."""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="session")
def test_app() -> FastAPI:
# Avoid circular import
from app.main import create_app
app = create_app()
return app
@pytest.fixture(scope="function")
def db_session():
# Create the SQLite database engine
engine = create_engine(TEST_DATABASE_URL)
# Create all tables
Base.metadata.create_all(engine)
# Create a new session
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
# Drop all tables after the test
Base.metadata.drop_all(engine)
@pytest.fixture(scope="function")
def client(test_app: FastAPI, db_session):
# Override the get_db dependency to use the test database
def override_get_db():
try:
yield db_session
finally:
pass
test_app.dependency_overrides[get_db] = override_get_db
# Use the FastAPI TestClient
with TestClient(test_app) as test_client:
yield test_client
@pytest_asyncio.fixture(scope="function")
async def async_client(test_app: FastAPI, db_session):
# Override the get_db dependency to use the test database
def override_get_db():
try:
yield db_session
finally:
pass
test_app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(app=test_app, base_url="http://testserver") as ac:
yield ac
@pytest.fixture(scope="function")
def test_user(db_session):
"""Create a test user in the database."""
user = User(
email="test@example.com",
hashed_password=get_password_hash("password"),
is_active=True,
is_superuser=False,
is_verified=True
)
db_session.add(user)
db_session.commit()
db_session.refresh(user)
return user
+61
View File
@@ -0,0 +1,61 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.domain import Domain
def test_read_health(client: TestClient):
"""Test health check endpoint"""
response = client.get("/api/v1/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "version" in data
def test_read_domains_empty(client: TestClient):
"""Test reading domains when none exist"""
response = client.get("/api/v1/domains")
assert response.status_code == 200
data = response.json()
assert data == []
def test_read_domains(client: TestClient, db_session: Session):
"""Test reading domains"""
# Create some test domains
domain1 = Domain(name="example.com", description="Example Domain", active=True)
domain2 = Domain(name="test.com", description="Test Domain", active=True)
db_session.add_all([domain1, domain2])
db_session.commit()
response = client.get("/api/v1/domains")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
assert {"name": "example.com", "description": "Example Domain"}.items() <= data[0].items()
assert {"name": "test.com", "description": "Test Domain"}.items() <= data[1].items()
def test_create_domain(client: TestClient):
"""Test creating a new domain"""
response = client.post(
"/api/v1/domains",
json={"name": "newdomain.com", "description": "New Domain", "active": True}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "newdomain.com"
assert data["description"] == "New Domain"
assert data["active"] is True
assert "id" in data
# Check that the domain was actually created
response = client.get("/api/v1/domains")
assert response.status_code == 200
domains = response.json()
assert any(d["name"] == "newdomain.com" for d in domains)
+124
View File
@@ -0,0 +1,124 @@
import os
import pytest
from unittest.mock import patch, MagicMock
from xml.etree import ElementTree as ET
from app.services.dmarc_parser import (
DMARCParser,
parse_aggregate_report_xml,
parse_aggregate_report_zip,
)
class TestDMARCParser:
def setup_method(self):
"""Set up test fixtures"""
self.parser = DMARCParser()
# Sample XML string for testing
self.sample_xml = """<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>123456789</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>203.0.113.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
<selector>default</selector>
</dkim>
<spf>
<domain>example.com</domain>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
"""
def test_parse_aggregate_report_xml(self):
"""Test parsing an XML aggregate report"""
result = parse_aggregate_report_xml(self.sample_xml)
# Verify report metadata
assert result['report_metadata']['org_name'] == 'google.com'
assert result['report_metadata']['email'] == 'noreply-dmarc-support@google.com'
assert result['report_metadata']['report_id'] == '123456789'
assert result['report_metadata']['begin_date'] == 1597449600
assert result['report_metadata']['end_date'] == 1597535999
# Verify policy published
assert result['policy_published']['domain'] == 'example.com'
assert result['policy_published']['policy'] == 'none'
# Verify record data
assert len(result['records']) == 1
record = result['records'][0]
assert record['source_ip'] == '203.0.113.1'
assert record['count'] == 2
assert record['policy_evaluated']['disposition'] == 'none'
assert record['policy_evaluated']['dkim'] == 'pass'
assert record['policy_evaluated']['spf'] == 'fail'
assert record['identifiers']['header_from'] == 'example.com'
@patch('app.services.dmarc_parser.zipfile.ZipFile')
def test_parse_aggregate_report_zip(self, mock_zipfile):
"""Test parsing a zipped aggregate report"""
# Setup mock zipfile extraction
mock_zip_instance = MagicMock()
mock_zipfile.return_value.__enter__.return_value = mock_zip_instance
mock_zip_instance.namelist.return_value = ['report.xml']
mock_zip_instance.read.return_value = self.sample_xml.encode('utf-8')
result = parse_aggregate_report_zip('/fake/path/report.zip')
# Assertions similar to test_parse_aggregate_report_xml
assert result['report_metadata']['org_name'] == 'google.com'
assert len(result['records']) == 1
def test_extract_authentication_results(self):
"""Test extracting authentication results from report"""
# Parse the sample XML
root = ET.fromstring(self.sample_xml)
record_elem = root.find('./record')
auth_results = self.parser._extract_authentication_results(record_elem)
# Verify DKIM results
assert len(auth_results['dkim']) == 1
assert auth_results['dkim'][0]['domain'] == 'example.com'
assert auth_results['dkim'][0]['result'] == 'pass'
assert auth_results['dkim'][0]['selector'] == 'default'
# Verify SPF results
assert len(auth_results['spf']) == 1
assert auth_results['spf'][0]['domain'] == 'example.com'
assert auth_results['spf'][0]['result'] == 'fail'
+151
View File
@@ -0,0 +1,151 @@
import pytest
from sqlalchemy.orm import Session
from app.models.domain import Domain
from app.models.report import DMARCReport, ReportRecord
class TestDomainModel:
"""Tests for the Domain model"""
def test_create_domain(self, db_session: Session):
"""Test creating a domain in the database"""
domain = Domain(
name="example.com",
description="Test domain",
active=True,
dmarc_policy="quarantine"
)
db_session.add(domain)
db_session.commit()
db_session.refresh(domain)
assert domain.id is not None
assert domain.name == "example.com"
assert domain.description == "Test domain"
assert domain.active is True
assert domain.dmarc_policy == "quarantine"
def test_domain_reports_relationship(self, db_session: Session):
"""Test the relationship between domains and DMARC reports"""
# Create a domain
domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
# Create reports for the domain
report1 = DMARCReport(
domain_id=domain.id,
report_id="report1",
org_name="Google",
begin_date=1597449600,
end_date=1597535999,
source_email="noreply-dmarc-support@google.com"
)
report2 = DMARCReport(
domain_id=domain.id,
report_id="report2",
org_name="Microsoft",
begin_date=1597536000,
end_date=1597622399,
source_email="dmarc@microsoft.com"
)
db_session.add_all([report1, report2])
db_session.commit()
# Query the domain and check its reports
domain = db_session.query(Domain).filter_by(name="example.com").first()
assert domain is not None
assert len(domain.reports) == 2
assert domain.reports[0].report_id in ["report1", "report2"]
assert domain.reports[1].report_id in ["report1", "report2"]
class TestDMARCReportModel:
"""Tests for the DMARCReport model"""
def test_create_report(self, db_session: Session):
"""Test creating a DMARC report in the database"""
# Create a domain first
domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
# Create a report
report = DMARCReport(
domain_id=domain.id,
report_id="123456789",
org_name="Google",
begin_date=1597449600,
end_date=1597535999,
source_email="noreply-dmarc-support@google.com",
policy="none",
adkim="r",
aspf="r",
percentage=100
)
db_session.add(report)
db_session.commit()
db_session.refresh(report)
assert report.id is not None
assert report.domain_id == domain.id
assert report.report_id == "123456789"
assert report.org_name == "Google"
assert report.begin_date == 1597449600
assert report.policy == "none"
def test_report_records_relationship(self, db_session: Session):
"""Test the relationship between reports and records"""
# Create domain and report
domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
report = DMARCReport(
domain_id=domain.id,
report_id="123456789",
org_name="Google",
begin_date=1597449600,
end_date=1597535999,
source_email="noreply-dmarc-support@google.com"
)
db_session.add(report)
db_session.commit()
# Create records for the report
record1 = ReportRecord(
report_id=report.id,
source_ip="203.0.113.1",
count=2,
disposition="none",
dkim="pass",
spf="fail",
header_from="example.com",
envelope_from=None
)
record2 = ReportRecord(
report_id=report.id,
source_ip="203.0.113.2",
count=5,
disposition="none",
dkim="pass",
spf="pass",
header_from="example.com",
envelope_from=None
)
db_session.add_all([record1, record2])
db_session.commit()
# Query the report and check its records
report = db_session.query(DMARCReport).filter_by(report_id="123456789").first()
assert report is not None
assert len(report.records) == 2
assert report.records[0].source_ip in ["203.0.113.1", "203.0.113.2"]
assert report.records[1].source_ip in ["203.0.113.1", "203.0.113.2"]
+166
View File
@@ -0,0 +1,166 @@
import pytest
import io
import zipfile
import os
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.domain import Domain
def test_read_reports_empty(client: TestClient):
"""Test reading reports when none exist"""
response = client.get("/api/v1/reports")
assert response.status_code == 200
data = response.json()
assert data == []
def test_upload_report_no_domain(client: TestClient):
"""Test uploading a report when domain doesn't exist"""
# Create a simple XML report
xml_content = """<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>123456789</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>nonexistentdomain.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>203.0.113.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>nonexistentdomain.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>nonexistentdomain.com</domain>
<result>pass</result>
<selector>default</selector>
</dkim>
<spf>
<domain>nonexistentdomain.com</domain>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
"""
# Create an in-memory zip file
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
zip_file.writestr('report.xml', xml_content)
zip_buffer.seek(0)
# Upload the zip file
response = client.post(
"/api/v1/reports/upload",
files={"file": ("report.zip", zip_buffer, "application/zip")}
)
# Should return an error since domain doesn't exist
assert response.status_code == 404
data = response.json()
assert "domain not found" in data["detail"].lower()
def test_upload_report_success(client: TestClient, db_session: Session):
"""Test successfully uploading a report"""
# Create a domain first
domain = Domain(name="example.com", active=True)
db_session.add(domain)
db_session.commit()
# Create a simple XML report
xml_content = """<?xml version="1.0" encoding="UTF-8" ?>
<feedback>
<report_metadata>
<org_name>google.com</org_name>
<email>noreply-dmarc-support@google.com</email>
<report_id>123456789</report_id>
<date_range>
<begin>1597449600</begin>
<end>1597535999</end>
</date_range>
</report_metadata>
<policy_published>
<domain>example.com</domain>
<adkim>r</adkim>
<aspf>r</aspf>
<p>none</p>
<sp>none</sp>
<pct>100</pct>
</policy_published>
<record>
<row>
<source_ip>203.0.113.1</source_ip>
<count>2</count>
<policy_evaluated>
<disposition>none</disposition>
<dkim>pass</dkim>
<spf>fail</spf>
</policy_evaluated>
</row>
<identifiers>
<header_from>example.com</header_from>
</identifiers>
<auth_results>
<dkim>
<domain>example.com</domain>
<result>pass</result>
<selector>default</selector>
</dkim>
<spf>
<domain>example.com</domain>
<result>fail</result>
</spf>
</auth_results>
</record>
</feedback>
"""
# Create an in-memory zip file
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w') as zip_file:
zip_file.writestr('report.xml', xml_content)
zip_buffer.seek(0)
# Upload the zip file
response = client.post(
"/api/v1/reports/upload",
files={"file": ("report.zip", zip_buffer, "application/zip")}
)
# Should be successful
assert response.status_code == 201
data = response.json()
assert data["success"] is True
assert "report_id" in data
# Check that the report was actually created
response = client.get("/api/v1/reports")
assert response.status_code == 200
reports = response.json()
assert len(reports) == 1
assert reports[0]["report_id"] == "123456789"
assert reports[0]["org_name"] == "google.com"
+27
View File
@@ -0,0 +1,27 @@
fastapi>=0.96.0
uvicorn>=0.22.0
sqlalchemy>=2.0.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.6
fastapi-users[sqlalchemy]>=12.0.0
alembic>=1.11.0
pytest>=7.3.1
pytest-asyncio>=0.21.0
httpx>=0.24.1
pytest-cov>=4.1.0
psycopg2-binary>=2.9.6
imap-tools>=1.0.0
defusedxml>=0.7.1
dynaconf>=3.1.12
python-dotenv>=1.0.0
tenacity>=8.2.2
apprise>=1.4.5
dnspython>=2.3.0
email-validator>=2.0.0
lxml>=4.9.2
zipfile36>=0.1.3
aiosmtplib>=2.0.2
jinja2>=3.1.2
BIN
View File
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
version: '3.8'
services:
# Database service
db:
image: postgres:14-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=dmarq_secure_password
- POSTGRES_USER=dmarq_user
- POSTGRES_DB=dmarq_db
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dmarq_user -d dmarq_db"]
interval: 5s
timeout: 5s
retries: 5
networks:
- dmarq-network
ports:
- "5433:5432" # Map to non-standard port to avoid conflicts
# Integrated backend with frontend service
app:
build:
context: ./backend
dockerfile: Dockerfile
volumes:
- ./backend:/app
depends_on:
db:
condition: service_healthy
environment:
- DATABASE_URL=postgresql://dmarq_user:dmarq_secure_password@db:5432/dmarq_db
- SECRET_KEY=your_secret_key_change_in_production
- DEBUG=True
- ENVIRONMENT=development
networks:
- dmarq-network
ports:
- "80:8080" # Map directly to port 80 for web access
# Define networks
networks:
dmarq-network:
driver: bridge
# Define volumes
volumes:
postgres_data:
driver: local
+216
View File
@@ -0,0 +1,216 @@
# DMARQ Architecture & Tech Stack Overview
**Project:** DMARQ
**Host:** https://app.dmarq.org
**Purpose:** Self-hosted, full-featured DMARC monitoring tool with support for Cloudflare integration, alerting, and visual dashboards.
---
## 🧱 System Architecture
DMARQ uses an integrated architecture with:
- **Unified Backend** (FastAPI with Jinja2 templates)
- **Modern UI** (Jinja2 + Tailwind CSS + shadcn/ui)
The application is deployed via Docker with a PostgreSQL database storing parsed reports, domain configurations, DNS snapshots, and user information.
Optional services (e.g., Apprise for alerts) are included via container or integrated via API calls.
---
## 🧩 Tech Stack
| Component | Stack / Tooling |
|--------------------|---------------------------------------------|
| **Frontend** | Jinja2 Templates + HTMX + Tailwind CSS + shadcn/ui |
| **Charts** | Chart.js with Alpine.js integration |
| **Routing/Auth** | FastAPI routing + JWT auth (FastAPI Users) |
| **Backend** | FastAPI + SQLAlchemy |
| **ORM & DB** | SQLAlchemy ORM, PostgreSQL |
| **IMAP** | `imap-tools`, `aioimaplib` |
| **DMARC Parsing** | `defusedxml`, `lxml`, `zipfile`, `mail-parser` |
| **Cloudflare API** | `cloudflare` Python SDK or raw REST client |
| **DNS Resolution** | `dnspython` |
| **Authentication** | FastAPI Users (JWT + optional OAuth later) |
| **Alerting** | [Apprise](https://github.com/caronc/apprise) |
| **Testing** | `pytest`, `coverage`, `pytest-mock` |
| **CI/CD (optional)**| GitHub Actions, Docker Hub |
| **Deployment** | Docker, Docker Compose |
| **Config Mgmt** | `dynaconf` (ENV + DB integration) |
---
## 📦 Application Structure
```
dmarq/
├── app/
│ ├── api/ # REST API endpoints (v1)
│ ├── core/ # App config, security, constants
│ ├── models/ # SQLAlchemy ORM models
│ ├── services/ # Mail parsing, DNS, CF integrations
│ ├── static/ # CSS (Tailwind), JS, images
│ │ ├── css/ # Generated Tailwind styles
│ │ ├── js/ # Alpine.js and other frontend scripts
│ │ └── img/ # Images and icons
│ ├── tasks/ # Scheduled tasks (polling, DNS sync)
│ ├── templates/ # Jinja2 templates
│ │ ├── components/ # Reusable UI components
│ │ ├── dashboard/ # Dashboard views
│ │ ├── layouts/ # Base layouts
│ │ ├── reports/ # Report-specific templates
│ │ └── wizard/ # Setup wizard templates
│ ├── tests/ # Unit + integration tests
│ └── main.py # FastAPI application entrypoint
├── Dockerfile
├── docker-compose.yml
├── config/
│ └── seed_env.py # Load ENV vars into DB
├── .env.example
├── README.md
└── ARCHITECTURE.md
```
---
## 🌐 Functional Modules
### 1. **Config Wizard (Web-Based)**
- First step of app usage
- Collects:
- Admin user creation
- IMAP mailbox login
- Cloudflare API token
- Optional alert channels
- Saves config into DB
- Optionally seeded from `.env`
---
### 2. **Email Processing**
- IMAP polling for inbox (e.g. `dmarc@yourdomain.com`)
- Download zipped aggregate XML or forensic reports
- Parse with validation and deduplication
- Store:
- Reporting org
- Source IPs, volume
- SPF/DKIM result
- Applied disposition (none, quarantine, reject)
- Forensics: failed messages, sample data
---
### 3. **Cloudflare DNS Sync**
- List zones and domains via API
- Pull DNS records:
- DMARC
- SPF
- DKIM
- MX
- BIMI (optional)
- Validate correctness and format
- Generate actionable **fix suggestions**
- DNS updates require manual user approval
---
### 4. **Alerting (via Apprise)**
- Alert on:
- New forensic reports
- New source IPs failing SPF/DKIM
- Compliance drops (configurable)
- Supports:
- Email
- Slack
- Discord
- Webhooks
- Matrix
- Configurable via web wizard and/or user dashboard
---
### 5. **User Authentication**
- FastAPI Users backend
- JWT token auth
- Server-side sessions with secure cookies
- Admin-only access to DNS fix or config modules
---
## 🧪 Testing Strategy
- **Unit Tests:** All parsing, validation, config, services
- **Mock External Services:** IMAP, Cloudflare, DNS
- **Frontend:** Testing Jinja templates with pytest-html
- **E2E (later):** Playwright or Selenium
Run with:
```bash
docker compose exec app pytest
```
---
## 🧑‍🎨 UI Implementation
### Frontend Technology
DMARQ uses an integrated approach with:
1. **Jinja2 Templates**: Server-side rendering of HTML
2. **Tailwind CSS**: Utility-first CSS framework for styling
3. **shadcn/ui**: Component library adapted for server-rendered templates
4. **Alpine.js**: Minimal JavaScript framework for enhanced interactivity
5. **HTMX**: For AJAX requests without writing JavaScript
6. **Chart.js**: For data visualization components
This approach offers several advantages:
- Eliminates API-related complexity
- Reduces JavaScript bundle size
- Improves initial page load performance
- Simplifies deployment (single container)
- Server-side rendering improves SEO
### UI Components Structure
- **Layouts**: Base templates that define the page structure
- **Components**: Reusable UI elements like cards, tables, and forms
- **Pages**: Full page templates for dashboard, reports, settings, etc.
The components follow shadcn/ui design patterns but are implemented as Jinja2 macros or includes rather than React components.
---
## 🧑‍🎨 Branding & UI Design
- **Logo:** Stylized shield with "Q" + monogram "D+Q"
- **Colors:**
- Deep Blue `#1A237E`
- Teal `#00ACC1`
- Orange `#FF7043`
- Light Gray `#F5F5F5`, Dark Gray `#212121`
- **Fonts:** Montserrat (headings), Open Sans (body)
- **Style:** Minimal, modern, flat icons — inspired by EasyDMARC
---
## 🚧 Known Constraints
- Single-instance deployment (no horizontal scaling)
- Target performance: 50100 domains per instance
- Database optimization is secondary
- Multitenancy is not supported (yet)
---
## 📘 License
Apache License 2.0 — Free for personal or commercial use with attribution.
**License Compatibility:**
All major dependencies and tools used in DMARQ (including FastAPI, SQLAlchemy, Tailwind CSS, shadcn/ui, Alpine.js, HTMX, Chart.js, Apprise, and others) are distributed under permissive licenses (MIT, BSD, Apache 2.0, ISC, or similar) and are compatible with the Apache 2.0 license.
---
This document serves as the technical foundation for the implementation of DMARQ.
+122
View File
@@ -0,0 +1,122 @@
Perfect. I'll now create a full milestone and architecture strategy for DMARQ — starting with an MVP for five domains, then expanding toward the complete product vision. Each step will be modular, testable, and deployable within a few hours using GitHub Copilot.
I'll summarize the entire development arc shortly, including clear milestones, architectures per phase, and user stories that guide implementation.
# DMARQ Development Roadmap
## Project Overview and Positioning
- **Purpose:** **DMARQ** is a self-hosted **DMARC monitoring and visualization tool**. It provides professional visibility into email authentication (DMARC) reports without relying on any third-party services, keeping sensitive email data private.
- **Target Users:** Designed for **IT administrators and security-conscious users** who manage one or many domains. It helps them ensure DMARC compliance and email security while maintaining full control of data.
- **Technology Stack:** Built with a **FastAPI** backend and server-rendered **Jinja2** templates for the frontend. Styling uses **Tailwind CSS** (leveraging component kits like shadcn/ui or Preline for rapid UI development). No React or separate frontend app is used the web interface is served directly by FastAPI.
- **Deployment:** Distributed as a self-contained Docker image, intended to be run via **Docker Compose** for easy setup. All components (web UI, database, background tasks) run within this service, on-premises.
- **Roadmap Scope:** The development will start with a **Minimal Viable Product (MVP)** supporting a few domains, then progress through incremental milestones. Each milestone delivers a working, testable product with new features or improvements, achievable in a few hours of development (especially with GitHub Copilot assistance). Over successive milestones, DMARQ will grow to handle **50100 domains**, add advanced features like alerting, DNS configuration guidance, Cloudflare integration, historical trends, authentication, and more all while preserving its privacy-centric, self-hosted ethos.
## Milestone 1: Minimal Viable Product (MVP) Basic DMARC Monitoring (up to 5 domains)
- **Scope & Features:** Implements core DMARC report processing and a simple dashboard for a small number of domains (e.g. up to five). The MVP can ingest **DMARC aggregate reports** (RUAs) and display summary results per domain. Initially, report files might be provided manually (for example, uploading XML or zip files containing DMARC reports via the web UI). The frontend presents a basic table or list of DMARC statistics for each domain such as total emails analyzed, percentage that passed DMARC checks, and any failures. This allows an admin to get immediate insight into DMARC compliance for their domains in a local web interface.
- **Architecture:** The application is a single FastAPI service serving HTML pages. DMARC report parsing is handled in Python (for speed, one could leverage an existing library like **parsedmarc**, which parses both aggregate and forensic reports into JSON ([parsedmarc documentation - Open source DMARC report analyzer and visualizer — parsedmarc 8.18.1 documentation](https://domainaware.github.io/parsedmarc/#:~:text=Features%EF%83%81))). Parsed data (e.g. per sending source IP: how many emails, SPF/DKIM pass rates, policy applied) is stored in-memory or in a lightweight local store. The UI is built with Jinja2 templates styled by Tailwind CSS components, ensuring a clean, responsive design without a heavy frontend framework. No database is required at this stage (data can be kept in memory during runtime), which is manageable given the low volume (handful of domains and recent reports). There is no authentication yet (open access locally), simplifying setup.
- **User Stories:**
- *As an IT admin,* I can upload DMARC aggregate report files (XML from my email provider) to DMARQ and see a dashboard of DMARC results for up to five of my domains, so that I can start monitoring email authentication status immediately.
- *As a security-conscious user,* I can deploy the DMARQ MVP via Docker Compose on my own server and review DMARC compliance data through a web dashboard, **ensuring none of my email report data is sent to any third-party service**.
- *As an email administrator,* I can view for each domain a list of sending sources (IP addresses or hostnames) and whether they passed or failed DMARC checks, so that I understand which senders are authorized and which might be illegitimate.
## Milestone 2: Automated Report Ingestion (IMAP Mailbox Integration)
- **Scope & Features:** This milestone eliminates manual report handling by introducing **automated retrieval of DMARC reports from an email inbox**. DMARQ will connect to a configured IMAP mailbox that collects DMARC aggregate reports. It will periodically fetch new emails, extract the DMARC report attachments, parse them, and update the dashboard data. The system can be configured with the IMAP server details (host, port, credentials) via environment variables or a simple config file. This allows continuous monitoring without user intervention new reports are ingested on schedule (e.g. hourly or daily). The front-end might also get a refresh button or indicator showing the last fetch time and allowing on-demand sync.
- **Architecture:** A background task or scheduler is added to the FastAPI app to handle periodic mailbox polling (for example, using FastAPIs background tasks or an APScheduler job started on app startup). On each run, the IMAP client logs in and searches for unread DMARC reports (by sender or subject). For each new report email found, DMARQ downloads the attachment (handling zip or gzip compression), then parses the XML content (using the same parser from MVP). Parsed records are then merged into the in-memory data store. Processed emails can be marked as read or moved to an archive folder to avoid duplication. The rest of the stack remains the same FastAPI endpoints for display, etc. (If an external library like parsedmarc is used, it can even directly fetch from IMAP and return structured data, simplifying implementation).
- **User Stories:**
- *As an IT admin,* I can configure DMARQ with my DMARC report mailbox credentials and have the system automatically retrieve and process incoming DMARC reports. This means I no longer need to manually upload files DMARQ stays up-to-date with the latest reports on its own.
- *As a user,* when I check the DMARQ dashboard each day, I see that new DMARC statistics have been added automatically (e.g. the previous days email stats appear after the daily reports arrive), giving me timely insights without manual effort.
- *As a systems engineer,* I want the DMARQ service to run continuously and keep itself updated, so I set it up in Docker Compose with the IMAP settings. The service logs or UI indicates when it last fetched reports, so I have confidence that the data is current.
## Milestone 3: Persistence and Multi-Domain Scaling (50100 Domains Support)
- **Scope & Features:** This milestone upgrades the backend to handle a **larger number of domains and reports** reliably. It introduces a **database layer** for persistence and efficient data querying. With this change, DMARQ can support dozens of domains (50100) and a higher volume of reports over time. The product now includes domain management capabilities for example, an interface to add or remove domains being monitored and ensures data is retained across restarts. The UI may be enhanced to allow filtering or selecting a domain to view (if many domains are present). By the end of this milestone, DMARQ is suitable for an organization with a large domain portfolio, not just a handful.
- **Architecture:** A lightweight relational database (e.g. SQLite for simplicity, or PostgreSQL if scaling beyond) is integrated via an ORM. Data models are defined for **Domain**, **AggregateReport**, and related entities (e.g. per-sender record details). When IMAP-fetched reports are parsed, their data is now saved to the DB. This allows persistent storage of historical records and efficient lookups (instead of keeping everything in memory). The domain management feature means there is a Domain table the user can explicitly add domains they want to monitor. The system will filter incoming reports to only store data for configured domains (or conversely, auto-populate the domain list from reports, with the option to ignore some). The FastAPI app now uses database queries to populate templates. For example, the dashboard might show a list of domains with summary stats, querying aggregated info per domain from the DB. To handle more data, the UI might introduce pagination or summary views (e.g. show only top 5 sending sources by volume, with an option to view all). The overall architecture is adjusted to ensure that increasing the number of domains or reports doesnt degrade performance for instance, heavy parsing could be done in the background job, and the web requests simply read from the prepared database tables.
- **User Stories:**
- *As an administrator with many domains under management,* I can add all my domains to DMARQ and trust that it will track DMARC reports for each domain. The system can handle my 50+ domains without losing data or slowing down, so I have a one-stop dashboard for all domains email compliance.
- *As a user,* if I restart or update the DMARQ Docker container, all previously collected DMARC data is still there when it comes back up, because the data is stored persistently in a database. I dont have to worry about backups of XML files or memory the system retains history automatically.
- *As an IT manager,* I can navigate the DMARQ UI to see an overview of all monitored domains and then drill down into a specific domains details. Even with 100 domains configured, I can quickly find a particular domain (via a filter or search) and view its DMARC compliance status, which is crucial for managing a large environment.
## Milestone 4: Dashboard Enhancements and Historical Visualization
- **Scope & Features:** Now that DMARQ has robust data storage, this milestone focuses on **visualizing trends and enhancing the dashboard UI**. It introduces charts and graphs for historical DMARC data and other summary widgets, transforming the dashboard into a more insightful overview of email authentication health. For example, the dashboard may display the **DMARC compliance rate** (percentage of emails passing DMARC) over the last 7 or 30 days, the **enforcement rate** (percentage of domains with strict policies), total email volume processed, and active alert counts (once alerts are implemented). Each domains page might include a timeline chart of daily email volumes and DMARC pass/fail rates. Additionally, cross-domain summary visuals can be added e.g. a pie chart of DMARC policies in use across all domains (how many domains have `p=none`, `quarantine`, `reject`), and indicators of how many domains have proper SPF/DKIM/DMARC (this part ties into DNS checks in a later milestone but can be stubbed or updated later).
- **Architecture:** The front-end now incorporates a charting solution. Since we avoid heavy front-end frameworks, we can use a simple JS library like **Chart.js** (loaded via CDN) or generate charts server-side as images. The FastAPI endpoints can supply data to charts either by embedding JSON in the page or providing a small API endpoint that returns data for a chart (which the JS code calls via AJAX). Tailwind CSS components from shadcn/ui or Preline can be used to create card-like widgets on the dashboard for key metrics. For example, a card showing “DMARC Compliance Rate: 62%” with a small trend indicator. The system calculates historical metrics from the DB (for instance, each days compliance % per domain, or aggregate for all domains) possibly pre-computing these in the background for efficiency. The UI/UX is refined to be more navigable: a sidebar menu listing sections (Dashboard, Domains, Reports, Alerts, Settings, etc.), consistent with the single-page app feel but implemented with multi-page Jinja templates. This milestone is about presenting existing data in a more user-friendly, insightful way; no major new backend logic beyond queries for time-series data.
- **User Stories:**
- *As an IT admin,* I can view graphs on the DMARQ dashboard that show trends like how my DMARC compliance has improved over the past week and the volume of emails my domains receive daily. This visual context helps me quickly assess whether changes we made (e.g., adding SPF records or changing DMARC policy) are improving email security.
- *As a security engineer,* I have a clear **dashboard** that highlights key metrics: what percentage of our emails are passing DMARC, how many domains are at a reject policy, how many total emails were seen, and if there are any active issues. This at-a-glance information (with charts and colored indicators) allows me to report the organizations email security posture to management easily.
- *As a user,* I find the interface intuitive: I can select a specific domain and see its detailed report history in both tabular and graphical form. For example, I select “example.com” and see a line chart of daily DMARC pass rates and a breakdown of senders this helps me to pinpoint when a new unauthorized sender started failing DMARC, in a visual way.
## Milestone 5: Authentication and Access Control (FastAPI Users Integration)
- **Scope & Features:** At this stage, DMARQ gains **user authentication and access control** to secure the interface. Rather than being openly accessible, the application now requires a login. This is important as the tool may be hosted in environments where multiple people or even the internet could access it; we want to ensure only authorized admins can view or change settings. We integrate a standard user management system with features like user registration (for an initial admin), login, and (optionally) password reset. For now, DMARQ might be single-user (one admin account) or a small set of users (if a team of admins), but we use a scalable auth framework to accommodate growth.
- **Architecture:** We utilize **FastAPI Users**, a robust plug-and-play authentication library for FastAPI. This provides ready-made routes for authentication and user management (including hashing passwords, JWT or session cookie management, etc.) ([GitHub - fastapi-users/fastapi-users: Ready-to-use and customizable users management for FastAPI](https://github.com/fastapi-users/fastapi-users#:~:text=Image%3A%20FastAPI%20Users)) ([GitHub - fastapi-users/fastapi-users: Ready-to-use and customizable users management for FastAPI](https://github.com/fastapi-users/fastapi-users#:~:text=Add%20quickly%20a%20registration%20and,customizable%20and%20adaptable%20as%20possible)). A new database table for users (and perhaps OAuth or verification tokens) is introduced. The initial admin user can be created via environment variables (for example, provide an initial admin email and password hash to auto-create on startup) or through a CLI setup command. FastAPI Users is configured to use the same database (with SQLAlchemy or similar) that DMARQ already uses. We decide on an authentication strategy likely cookie-based sessions for a web app (so the admin logs in via a form, and a secure session cookie keeps them logged in). All previously open endpoints (dashboard, domain pages, etc.) are now protected by an `@login_required` dependency, so unauthenticated requests redirect to a login page. The UI gets new pages: **Login** (and possibly **Register** or **Password Reset** if we allow multiple users or self-service account creation). Tailwind UI components are used to style the login form and any auth-related pages in a consistent manner. Access control at this point is straightforward (any logged-in user is considered an admin with full access). If needed, we can tag certain users as read-only vs admin, but that might be overkill for now.
- **User Stories:**
- *As the system owner,* I want DMARQ to be password-protected. Now when I access the DMARQ URL, I am prompted to log in. I use the admin credentials I set up, and then I can see the dashboard. If an unauthorized person tries to access it, they cannot get in without the credentials, ensuring my email report data stays confidential.
- *As an admin user,* I can manage my credentials (e.g., change my password) and potentially add another colleague as a user. This way, our security team can have separate logins to the DMARQ dashboard.
- *As a new user (if multiple users allowed),* I can sign up or be invited to DMARQ with my email and a password. Once I verify my email (if that feature is enabled) and log in, I can view the DMARC monitoring info. (Initially, we might skip email verification for simplicity, but FastAPI Users supports it if needed.)
## Milestone 6: Apprise-Based Notification Alerting (Basic Alerts)
- **Scope & Features:** This milestone introduces **alerting capabilities** so that DMARQ can actively notify administrators of important events rather than relying on them to check the dashboard. We implement basic rules for alerts (built-in for now) and use **Apprise** a notification library to send out these alerts via various channels (email, Slack, etc.). Initially, the alert triggers can be simple and fixed: for example, send an alert when a new DMARC report comes in that contains any failure (unauthorized use) above a certain threshold, or send a **daily summary report** of DMARC statistics to the admins email. The system will also have a configuration for the admin to provide their notification endpoints (e.g., an email address or a Slack webhook URL). Using Apprise means DMARQ can support many notification methods through a single integration.
- **Architecture:** We integrate the **Apprise** Python library for notifications. Apprise allows sending to almost all popular services (Telegram, Discord, Slack, email, etc.) with a common API ([GitHub - caronc/apprise: Apprise - Push Notifications that work with just about every platform!](https://github.com/caronc/apprise#:~:text=Apprise%20allows%20you%20to%20send,Slack%2C%20Amazon%20SNS%2C%20Gotify%2C%20etc)). We add a configuration setting (stored in the DB or .env) where the admin supplies an Apprise “URL” for their desired channel (for example, an SMTP email URL or Slack webhook URL). When new DMARC reports are processed (in the background task from Milestone 2), after updating the database, the system evaluates the built-in alert conditions. A simple condition might be: “if any domain in this batch of reports has DMARC failures > 0, send an alert” or “if a new sending source (never seen before) appears, alert the user.” If the condition is met, DMARQ composes a notification message (e.g., “DMARQ Alert: 5 emails failed DMARC for domain X in todays report.”) and calls Apprise to dispatch it to the configured channels. This happens asynchronously (so it doesnt slow down the main thread Apprise can be called in a background thread or during the fetch routine). We also consider adding an **“Alerts” page in the UI** where recent alerts are listed for reference. This page would pull from an Alert log table if we store alerts, or it might simply show the last alert time/status. In this basic alerting phase, the rules are not yet user-customizable (that comes next), but the user can at least turn alerts on/off or change the notification endpoint.
- **User Stories:**
- *As an admin,* I want DMARQ to proactively notify me when something potentially problematic occurs. For example, if a DMARC report indicates someone tried to spoof our domain (DMARC failure), I receive an **alert via my chosen channel** (for instance, a message on Slack or an email). This way, I am aware of issues immediately without logging into the dashboard every day.
- *As a user,* I can configure where DMARQ sends alerts. I might provide an email address for notifications or a Slack webhook. The system supports a variety of channels through a unified configuration (thanks to Apprise), so I can choose the channel that best fits my workflow.
- *As an IT security lead,* I get a daily summary email from DMARQ each morning that tells me how many emails were processed for each domain and if there were any DMARC failures. This summary ensures I have a daily digest of our email authentication status, aligning with our policy to continuously monitor DMARC compliance.
## Milestone 7: Custom Rule-Based Alert Triggers (Advanced Alerting)
- **Scope & Features:** Building on the basic alerting, this milestone gives administrators the ability to **define custom alert rules and triggers**. Instead of only having the fixed alerts (e.g., any failure triggers an alert), the user can specify conditions that matter to them. For example: “Trigger an alert if more than 10% of emails for any domain fail DMARC in a day” or “Alert me if a new sending source appears for domain X” or “Notify if no DMARC reports have been received for a domain in 3 days (which could indicate reporting is broken).” We will add a UI for managing these rules creating, editing, and deleting alert conditions. Each rule will have parameters (domain scope, condition type, threshold values, etc.). This makes the alerting system much more flexible and reduces noise by allowing users to tailor alerts to their environment.
- **Architecture:** We introduce a **Rule/AlertRules** model in the database to store these user-defined rules. The UI (perhaps an “Alerts” or “Settings” section) provides a form to add a rule. For example, a rule might be: *Type:* Failure Rate, *Domain:* Any or a specific domain, *Threshold:* 10% (with maybe additional field for minimum volume to avoid small sample noise). Another rule type might be: *New Sender Alert* with an option to apply to all domains or a specific one. In the background processing loop, after fetching and storing reports, DMARQ will evaluate all active rules against the latest data. We may implement a small rule engine: e.g., for each domains stats in the latest report or in the last 24h, check the conditions. If a rule is satisfied, create an Alert event (and send it via Apprise as before). Possibly, to avoid duplicate alerts, we mark rules as triggered for a given day or until conditions normalize. The architecture should consider stateful vs stateless rules (some rules like “new sender” are event-based once a new sender is seen and alerted, we might not alert again for the same sender). We could maintain state of known senders per domain to detect “new” ones. This adds some complexity, but manageable in a few hours by leveraging the data in the DB (e.g., we know all sources seen so far for a domain, so if in the new report theres an IP not in that list, trigger the alert and then add it). The UI will display the list of configured rules, and allow toggling them on/off.
- **User Stories:**
- *As an admin,* I want to customize what situations trigger alerts. For example, I create a rule: “If any domains DMARC failure rate exceeds 5% in a day, send me an alert.” Later, if one of my domains has a lot of failed emails, DMARQ sends an alert as specified. This flexibility means I only get notified on conditions that I consider important, reducing false alarms.
- *As a security engineer,* I add a rule to notify me if a new email source starts sending as one of our domains. I specify the rule for all domains: “Alert on new sender.” DMARQ then tracks known senders, and when an unknown IP appears in a DMARC report, I get an alert with details. This helps me catch potentially malicious actors or new services that we havent authorized.
- *As a user,* I can adjust or turn off certain alerts. For instance, once weve fully deployed DMARC, I might not need daily summaries anymore, so I disable that rule and instead keep only the high-severity alerts. The systems rule list gives me control to fine-tune notifications easily via the web UI.
## Milestone 8: Cloudflare Integration for DNS Health Checks and Guidance
- **Scope & Features:** This milestone extends DMARQs capabilities beyond reading email reports by integrating with DNS to provide **DMARC/SPF/DKIM record health checks and guidance**. Specifically, if the users domains are managed in Cloudflare, DMARQ can use the Cloudflare API to automatically fetch DNS records and detect issues. Even for non-Cloudflare users, DMARQ can perform DNS queries, but the Cloudflare integration streamlines things for those using that platform (and potentially allows easy fixes in the future). Features introduced: On the dashboard (or a dedicated “DNS Health” page), the user can see the status of each domains DNS records relevant to email security whether a valid DMARC record exists, and if so, what the policy is; whether SPF and DKIM records are present and valid; and possibly BIMI or others if relevant. For each domain, DMARQ will highlight any problems (e.g., “No DMARC record found!” or “SPF record exists but includes too many lookups” or “DMARC policy is none consider enforcing if ready”). Additionally, DMARQ will provide **recommended DNS record values** as guidance. For example, if a domain has no DMARC, it might suggest a baseline DMARC record (v=DMARC1; p=none; rua=… etc.) that the user can adopt. If the domain has DMARC but in monitor mode, and the reports show good compliance, it might suggest upgrading to quarantine/reject. The integration with Cloudflare means DMARQ can (with proper API credentials) pull the exact record values and potentially even offer a one-click update (though actual update could be a later enhancement; initially, we focus on read-only checks and guidance).
- **Architecture:** We add configuration for **Cloudflare API access** e.g., an API token that the user can obtain from Cloudflare. This token is stored (securely) in the DMARQ config. Using Cloudflares REST API, DMARQ can list DNS records for a given domains zone. The backend will implement a module to call these APIs (using Python `requests` or a Cloudflare SDK) to retrieve TXT records for DMARC (`_dmarc.domain`), SPF (the domains TXT that starts with v=spf1), and possibly DKIM selectors (which might need user to specify selector names, or we could attempt common names or parse from DNS). For each domain in our database, on a scheduled interval (maybe daily) or when the user clicks “Check DNS,” DMARQ fetches the latest DNS info. The results are stored or cached in a DNS status table. The logic then evaluates each:
- DMARC: Does a TXT record `_dmarc.domain` exist? If yes, parse it to see policy (p=) and addresses (rua, ruf). If no, mark as missing.
- SPF: Check for a TXT on the root domain (or subdomain if thats whats used for sending) containing `v=spf1`. If missing, thats an issue (though not directly DMARC, its related). If present, possibly validate that its syntactically correct and not exceeding DNS lookup limits.
- DKIM: If the user provided their DKIM selector(s), check those DNS records for existence and valid format (public key present). If not provided, we might skip or try to glean from reports which DKIM selector was used successfully/failed and then verify those.
- We then formulate suggestions: e.g., if DMARC missing => suggestion to add a DMARC record (with a specific example string). If DMARC policy is “none” and say more than 90% of emails are passing, we might suggest “You can consider p=quarantine or p=reject to enforce protection.” If SPF exists but has issues (like includes nonexistent domains or too many includes), flag that (though full SPF analysis might be complex, we can at least note length or existence).
- The front-end displays these findings in a clear way. For instance, on the **Domain Overview** page for each domain, show a section “DNS Records Status”: DMARC Valid/Invalid/Missing (with details), SPF Valid/Missing, DKIM Valid/Missing. Possibly use colored badges (green for ok, yellow for warning, red for problem). Additionally, on the main dashboard, we could have a widget summarizing “DNS Record Statuses” across all domains (like “5 domains valid DMARC, 2 warnings, 1 missing” etc.), similar to the example image.
- We ensure this feature is optional if no Cloudflare API token is provided, we fallback to direct DNS queries (which can still tell presence/absence). Writing back to DNS (like auto-fixing records) is not done at this stage to keep things read-only and safe.
- **User Stories:**
- *As an admin,* I can connect my Cloudflare account to DMARQ (by providing an API token) so that the tool can automatically check my DNS records for DMARC, SPF, and DKIM. When I open DMARQ, I immediately see which domains have proper records and which need attention for example, it flags that “example.com” has no DMARC record, alerting me to fix that in DNS.
- *As a user managing email security,* I appreciate that DMARQ gives me **guidance on DNS configuration**. After collecting reports for a while, DMARQ might show a suggestion: “Your domain abc.com is in monitoring mode (p=none) and has high compliance. Consider moving to p=quarantine for stronger enforcement.” This kind of recommendation helps me improve our security posture proactively.
- *As a DevOps engineer,* I use Cloudflare for DNS, so having DMARQ tie into it is convenient. On each domains page in DMARQ, I can see the actual current DMARC record and SPF record. If something is wrong (like a typo or missing record), DMARQ points it out. It even provides the exact DNS record string I should use. This saves me time I dont have to use separate tools to check DNS or recall the syntax for DMARC records.
## Milestone 9: Forensic Report (RUF) Support
- **Scope & Features:** DMARQ will now also handle **DMARC forensic (failure) reports** (the RUF aspect of DMARC). These forensic reports are detailed, single-incident reports sent when an email fails DMARC (if the DMARC policys `ruf` tag is set). They contain information such as the original email headers, sending source, and why it failed. In this milestone, DMARQ will collect, store, and display these failure reports in a dedicated section. This feature is important for deep dives into specific incidents it complements the aggregate reports by allowing the admin to examine individual spoofing attempts or misconfigurations in detail.
- **Architecture:** The IMAP fetching logic is extended to also retrieve **forensic report emails**. These might come from different senders (depending on receivers sending them) and have different formats (often an attachment in Abuse Report Format (ARF) or just an email with original message attached). If using the parsedmarc library, it can automatically parse forensic reports as well ([parsedmarc documentation - Open source DMARC report analyzer and visualizer — parsedmarc 8.18.1 documentation](https://domainaware.github.io/parsedmarc/#:~:text=Features%EF%83%81)), which we can leverage. Otherwise, we implement parsing: when a non-aggregate report email is encountered, we identify it as a forensic report (perhaps by headers or the presence of an “Feedback-Type: auth-failure” in the content). The parser will extract key fields such as: the domain, the original sender and subject, the source IP, the DKIM/SPF alignment results, and any other info from the report. This data is stored in a new **ForensicReport** table in the database, with a relationship to the Domain. We likely do not store the full email contents to keep DB lean (or we might store it as a blob for completeness, but not necessary to display all). Instead, store the pertinent fields. The front-end gets a new page or tab, e.g., **“Forensic Reports”** or on each domains page a section for recent forensic reports. The UI lists each forensic incident as a row with columns like Date, Sending IP, From address (or header), Subject (if available), and maybe a short reason (e.g., “SPF fail” or “DKIM fail”). The admin can click or expand a report to see more details (headers or the original DKIM signature that failed, etc.). We also include a note that forensic reports may contain sensitive info (since they can include portions of email). Authentication is already in place from milestone 5, so that adds a layer of protection. Optionally, we provide a toggle in settings to enable/disable forensic report collection in case some users prefer not to store these detailed reports (for privacy reasons), but by default if `ruf` is used, we collect them.
- **User Stories:**
- *As a security analyst,* when I suspect an email spoofing incident, I can look at the **DMARC forensic reports** in DMARQ to get detailed information. For example, I see a forensic report for domain X on yesterdays date, which shows an email claiming to be from our CEO (with his address) was sent from IP 1.2.3.4 and failed both SPF and DKIM. The forensic detail includes the mail headers, so I can see the attackers sending server info and the exact failure reason. This helps me investigate the incident thoroughly.
- *As an admin,* I have a separate view for “DMARC Failure Reports” (forensics) where each entry corresponds to a specific email that failed DMARC. I can quickly scan through these to see if there are any unusual or alarming incidents (like multiple failures from the same source might indicate a persistent attacker). This complements the aggregate view by providing concrete examples of failures.
- *As a user concerned about data volume,* I notice that forensic reports are less frequent but more detailed. DMARQ allows me to toggle collection of these reports. If I turn it on, I get rich data for each failure; if I turn it off, I only rely on aggregate data. By having this choice, I can balance detail vs. storage/privacy as needed.
## Milestone 10: Self-Service Setup Wizard (Web-Based Configuration)
- **Scope & Features:** This milestone greatly improves the initial onboarding of DMARQ by adding a **web-based setup wizard** that runs on first launch (or when configuration is not yet done). Instead of requiring manual environment variable setup or config file editing, a new user can navigate through a series of guided steps in their browser to configure DMARQ. The wizard will cover all essential configurations: creating the admin account, adding the first domains, setting up the email report mailbox connection, and optional integrations (Cloudflare API, notification settings). Its essentially a one-time assistant to get DMARQ up and running easily. After completion, DMARQ will use the saved configuration and proceed to operate normally. This caters to less technical users and speeds up deployment, while still allowing power users to bypass it with env vars if they prefer.
- **Architecture:** We implement a multi-step form flow in the FastAPI app. This might be done by having a distinct set of routes (e.g., `/setup` path) and templates that render each step. Steps likely include: (1) **Welcome** check prerequisites, start setup; (2) **Admin User** prompt for email and password for the admin account (unless already provided via env, in which case skip or pre-fill); upon submission, create this user in the DB using FastAPI Users; (3) **Domain Configuration** prompt for one or more domain names to monitor (the user can list their domains, maybe up to some number, and we create Domain entries in DB); (4) **DMARC Report Email Setup** ask whether they have an email inbox for reports and if yes, gather IMAP server, port, username, password (and possibly the sender addresses or subjects to filter, though not necessary if we handle generically); test the connection if possible (try logging in, and maybe fetch one recent email to verify); (5) **Integrations** offer to configure optional features: Cloudflare API token (for DNS checks), Apprise notification URL (test sending a test notification?), and whether to enable forensic reports collection; (6) **Review & Save** show a summary of settings entered, and allow confirmation. Once confirmed, save all config to the database (or .env as needed, but likely DB since the app is running inside container without writing to env). Mark a flag (like `setup_complete=True` in a config table or an environment variable) so that the wizard will not show up again on restart. The wizard pages themselves are protected by a temporary token or simply accessible because initially no config means no auth we should ensure that once the admin user is created at step 2, subsequent steps are either carried out under that new login or the wizard session continues securely. Since this is first-time setup, we can assume the user has exclusive access at that moment. After setup, the app redirects to the normal login or dashboard. Also, we ensure that if environment variables for all crucial settings are already provided (like in a non-interactive deployment), the wizard will auto-skip entirely. For instance, if an admin email/pass and at least one domain and IMAP settings are in env, DMARQ can detect that and mark setup as done. This satisfies headless setups via Docker Compose. The wizard uses the same styling, with a progress indicator for steps, and forms for input (Tailwind forms components).
- **User Stories:**
- *As a first-time user of DMARQ,* I can set it up without touching any config files. When I launch the app, Im greeted by a setup wizard. I create my admin login, enter my domain names, and provide the credentials of the email account where my DMARC reports are sent. The wizard is interactive and even tells me if something is wrong (like if it cant connect to the email server). In a few minutes, I finish the wizard and am taken to the dashboard, which now starts showing data. This guided process makes deployment **extremely user-friendly**.
- *As an administrator deploying via Docker Compose in a production setting,* I have the option to pre-configure DMARQ using environment variables (for automation). I can provide values for the admin account and other settings in the compose file. When the container starts, it recognizes that everything it needs is provided, so it **bypasses the wizard** and goes straight to normal operation (perhaps logging that setup was auto-configured). This flexibility means I can script deployments, but also have a fallback interactive mode for manual setups.
- *As a user revisiting the configuration,* if I ever need to change those initial settings, I have a “Settings” section (or I can rerun the wizard) to update things like adding more domains or changing the IMAP password. The system ensures that the configuration is not a one-time black box; the wizard writes to the same database that the app uses, so I can modify settings later through the normal UI (for example, an “Edit Domain” or “Add Domain” function, which was partly introduced in earlier milestones).
## Milestone 11: DNS Record Change History Tracking (Optional Enhancement)
- **Scope & Features:** The final milestone (optional but valuable for completeness) adds the ability to **track historical changes in DNS records** for the monitored domains. Since DMARQ is already checking DMARC/SPF/DKIM records (from Milestone 8), extending this to log changes over time provides an audit trail. The system will record whenever a domains DMARC, SPF, or DKIM record is changed, along with a timestamp and what changed. This helps administrators see, for example, if someone modified a DMARC policy or if a new SPF include was added, and when that happened. Its especially useful in teams where DNS changes might be done by different members it brings visibility into those changes in the context of email security. This feature will likely be toggleable, as its not critical for everyone, but when enabled it gives a historical view of DNS config alongside the email report data.
- **Architecture:** We build on the DNS checking mechanism introduced earlier. Each time DMARQ fetches the DNS records (whether via Cloudflare API or a DNS query), it will compare the current record values to the last known values stored in the database. We maintain a table, say **DNSRecordHistory**, that stores entries like: (domain, record type [DMARC/SPF/DKIM], timestamp, old value, new value). The first time we capture a record, we might log an “initial value” entry. On subsequent checks, if the value differs from the last logged value, we create a new history entry. For example, if `example.com` had DMARC `p=none` last week and now its `p=quarantine`, we add a record: Domain=example.com, Type=DMARC, Time=now, Old Value=`v=DMARC1; p=none; ...`, New Value=`v=DMARC1; p=quarantine; ...`. Similarly for SPF or DKIM (DKIM might have multiple selectors; we track each selector separately). We might limit history to changes only (not logging every daily check if unchanged, to save space). The UI will present this in a user-friendly way. Perhaps a “DNS Change Log” page or a section under each domains detail view. It could look like: a list of change events, or a timeline. For readability, we can show a sentence like “[Date]: DMARC record changed from X to Y” with maybe color highlighting differences. If no changes have happened, it might simply say “No DNS changes detected since [start date]”. This feature can be optional via a setting: if disabled, the system wont store these changes (some may not want the additional storage or have a very stable DNS and dont need it). Implementing this is relatively straightforward given the data we already gather its mostly about comparing strings and logging differences.
- **User Stories:**
- *As a domain administrator,* I want to know if and when someone changes a DMARC or SPF record for our domains. With DMARQs DNS history tracking, I can go to the “DNS Changes” log and see that “On 2025-04-01, the DMARC policy for domainX was changed from none to reject.” This helps with accountability and troubleshooting (e.g., if email suddenly started failing, I might see a change that caused it).
- *As a security auditor,* I can use the DNS change history to verify our compliance processes. For instance, I can confirm that we did implement the planned SPF update last month because DMARQ logs show the SPF record change on that date. If an unexpected change appears (one that I wasnt aware of), its a red flag that prompts me to investigate (maybe someone altered a record without following procedure).
- *As a user of DMARQ who might not need this feature,* I have the choice to turn off DNS history tracking if I find it unnecessary. DMARQ respects that by not recording every check. But if I enable it, the additional information is seamlessly captured without any manual work, leveraging the existing Cloudflare/DNS integration for maximum insight into my domains email security posture.
## Conclusion
Through these milestones, DMARQ evolves from a basic DMARC report viewer into a **full-featured email authentication monitoring platform**. Each milestone is designed to be completed in a short development burst, producing a functional increment. By the final stages, DMARQ offers a comprehensive suite of features: support for large domain sets, rich visual dashboards, proactive alerting, integration with DNS providers for guidance, detailed forensic analysis, and a user-friendly setup and management experience all while being self-hosted and privacy-conscious. This roadmap ensures that development can be scoped and prioritized, delivering continuous value to users who need professional DMARC insights without sacrificing control over their data.
+268
View File
@@ -0,0 +1,268 @@
Perfect, Ill start building an MVP for DMARQ with the full backend/frontend integration, IMAP ingestion and DMARC parsing, a web-based config wizard, and a modular design ready for Cloudflare integration and Apprise-based alerting later.
Ill begin scaffolding the repository and project components. Ill let you know once the MVP structure is ready for review and iteration.
# DMARQ: DMARC Monitoring Platform MVP
**DMARQ** is a full-stack DMARC monitoring platform designed to help organizations track and improve their email authentication. It processes DMARC reports (aggregate and forensic) and presents compliance insights via a user-friendly dashboard. This MVP will consist of a FastAPI backend to handle data collection, processing, and an authentication API, and a frontend built with Jinja2 templates, HTMX, and Alpine.js for an interactive dashboard and setup wizard. The platform follows clean code practices, uses modern libraries, and adheres to branding guidelines (DMARQ name, color scheme, and fonts) for a professional look.
## Backend (FastAPI) Architecture
The backend is built with **FastAPI**, chosen for its performance and intuitive async support for I/O tasks (like IMAP email fetching). The backends responsibilities include user authentication, scheduled retrieval of DMARC reports via IMAP, parsing those reports (both aggregate and forensic per RFC standards), storing results in a PostgreSQL database, and exposing APIs for the frontend. The code is organized into modular components (auth, polling, parsing, storage, alerts, etc.) to ensure maintainability and extensibility.
### Authentication with FastAPI Users
User management is handled by the **FastAPI Users** library, which provides ready-made routes and utilities for authentication. FastAPI Users supports JWT or cookie-based auth and integrates with SQLAlchemy for persistence ([GitHub - fastapi-users/fastapi-users: Ready-to-use and customizable users management for FastAPI](https://github.com/fastapi-users/fastapi-users#:~:text=Add%20quickly%20a%20registration%20and,customizable%20and%20adaptable%20as%20possible)). This gives DMARQ a secure registration and login system out-of-the-box, including endpoints for user signup, login, password reset, and email verification. For example, the FastAPI app can include the librarys routers for auth like so:
```python
# inside backend/app.py
app = FastAPI()
app.include_router(fastapi_users.get_auth_router(auth_backend), prefix="/auth/jwt", tags=["auth"])
app.include_router(fastapi_users.get_register_router(UserRead, UserCreate), prefix="/auth", tags=["auth"])
app.include_router(fastapi_users.get_reset_password_router(), prefix="/auth", tags=["auth"])
app.include_router(fastapi_users.get_verify_router(UserRead), prefix="/auth", tags=["auth"])
app.include_router(fastapi_users.get_users_router(UserRead, UserUpdate), prefix="/users", tags=["users"])
```
Using this approach, DMARQs backend instantly has secure JWT authentication and user management APIs ([Full example - FastAPI Users](https://fastapi-users.github.io/fastapi-users/10.1/configuration/full-example/#:~:text=app.include_router%28%20fastapi_users.get_auth_router%28auth_backend%29%2C%20prefix%3D,%29%20app.include_router)). The user model extends the base model from FastAPI Users (allowing fields like organization or role if needed). This library ensures password hashing, validation, and token generation are implemented following best practices, so the team can focus on DMARC-specific logic.
### IMAP Polling for DMARC Reports
To gather DMARC data, DMARQ polls an IMAP mailbox where aggregate (RUA) and forensic (RUF) reports are sent. Using Pythons IMAP libraries (e.g. `imaplib` or `IMAPClient`), the backend periodically connects to the configured mail server (interval configurable, e.g. every hour) to fetch new emails. It will search for unread messages from known reporter addresses or with subjects indicating DMARC reports (many reports include the domain and date). Each emails attachments are then processed:
- **Aggregate reports** (RUA) are typically attached as XML files (often zipped). The backend will detect attachments with MIME type “application/zip” or “application/gzip” and unzip them to obtain XML, or directly XML attachments. These XML files contain summary data about many emails authentication results.
- **Forensic reports** (RUF) are individual failure reports usually in an Abuse Report Format (ARF, per RFC 6591) essentially an email message or part of one that failed DMARC. These may be included as `.eml` attachments or in the email body.
The app uses a background task (such as FastAPIs **BackgroundTasks** or a dedicated Celery/RQ worker if scaling out) to perform IMAP polling so as not to block request handling. Upon retrieving new reports, the emails are marked as read (so they arent processed again), and then passed to the parsing module.
### Parsing DMARC Aggregate Reports (RFC 7489)
DMARC **aggregate reports** are XML documents that provide batched statistics about email authentication results ([EasyDMARC Blog | Understanding DMARC reports](https://easydmarc.com/blog/understanding-dmarc-reports/#:~:text=DMARC%20aggregate%20reports%20are%20XML,sensitive%20information%20about%20email%20messages)). Each report covers a period (usually 24 hours) and is sent by receiving servers to the domains rua address. These reports include fields such as the reporting organization, the sender domain, the DKIM/SPF alignment results, counts of emails, and the DMARC policy applied. Importantly, aggregate reports **do not contain personal email content** they just summarize authentication data.
The backend will parse the XML according to RFC 7489 structure. Key XML elements include: `<report_metadata>` (report ID, date range, reporter info), `<policy_published>` (the DMARC record of the domain, policy in effect), and multiple `<record>` entries which each contain an `<row>` (source IP, count, disposition, DKIM/SPF pass/fail) and corresponding `<identifiers>` (like the header-from domain) and `<auth_results>` (details on SPF/DKIM checks).
To implement parsing, the team can either write an XML parser using Pythons `xml.etree` or `defusedxml` (for security) or leverage an existing DMARC parsing library. One great option is **parsedmarc**, an open-source Python module that parses aggregate (and forensic) reports and even handles compressed files transparently ([parsedmarc documentation - Open source DMARC report analyzer and visualizer — parsedmarc 8.18.1 documentation](https://domainaware.github.io/parsedmarc/#:~:text=,standard%20aggregate%2Frua%20reports)). For example, parsedmarc can be used to parse XML content into Python dicts. Using a library like this speeds up development and ensures compliance with DMARC report formats. If using parsedmarc, the flow would be: download attachments -> feed them to parsedmarcs `parse_report_file` or `parse_aggregate_report_xml` function -> get structured data (Python dict or JSON) back.
After parsing, each aggregate report is stored in the database. The storage can include a **Reports** table for the metadata (reporting org, date range, domain, etc.) and a **Record** table for each source/result record, linked by a foreign key to the report. Storing normalized data allows flexible queries (e.g., calculating compliance rates, finding sources with fails). However, storing the raw JSON from parsedmarc in a JSONB column is another quick approach for MVP, with separate columns for key summary metrics (like total emails, pass count, fail count) to generate the dashboard stats.
### Parsing DMARC Forensic (Failure) Reports (RFC 6591)
**Forensic reports** (failure reports) are sent to the ruf address and contain details of individual emails that failed DMARC. They often use the standard ARF format (Abuse Reporting Format) to convey the original emails headers and info about the failure ([EasyDMARC Blog | Understanding DMARC reports](https://easydmarc.com/blog/understanding-dmarc-reports/#:~:text=Failure%20reports%20go%20to%20the,sources%20that%20need%20further%20configuration)). The backend needs to parse these to extract useful fields such as: the subject, source IP, sending server, headers like From, possibly snippets of the message or attachments that show why it failed (e.g., a DKIM signature that did not align).
Many email receivers no longer send forensic reports due to privacy concerns ([EasyDMARC Blog | Understanding DMARC reports](https://easydmarc.com/blog/understanding-dmarc-reports/#:~:text=Often%2C%20email%20services%20don%E2%80%99t%20provide,and%20acting%20on%20aggregate%20reports)). But when they are available, DMARQ will capture them. Using the Python `email` library, the backend can parse the `.eml` content. In ARF, the failure report will have a machine-readable part (with headers like “Feedback-Type: auth-failure”) and the original email attached (or headers thereof). The parser should pull out fields such as the reported domain, the authentication result that failed (SPF or DKIM), and any identifiers (like the header From domain). These are then stored in a **ForensicReport** table in the database, possibly with a reference to the aggregate report (if applicable) or at least to the domain and date.
Again, **parsedmarc** can assist here: it has the capability to parse forensic reports as well ([parsedmarc documentation - Open source DMARC report analyzer and visualizer — parsedmarc 8.18.1 documentation](https://domainaware.github.io/parsedmarc/#:~:text=,standard%20aggregate%2Frua%20reports)), returning standardized fields. This can save time. Either way, DMARQ will organize forensic data so that the frontend can display detailed information per incident, helping admins drill down into specific failures.
### Database Schema and SQLAlchemy Integration
A PostgreSQL database is used via SQLAlchemy ORM for reliable storage of DMARC data. The project will include SQLAlchemy models reflecting the data structure. Key models likely include:
- **User**: extends FastAPI Users base model (with fields like id, email, hashed password, is_active, etc.).
- **Domain**: the domain being monitored (in case the platform needs to support multiple domains in the future). Contains domain name and DMARC policy settings, etc. For MVP, this could be a single domain configured in the wizard.
- **AggregateReport**: stores metadata of each aggregate report (report_id, org_name, email, report_date_range, domain, policy_strictness, etc.).
- **AggregateRecord**: stores each source record from aggregate report with fields like source_ip, count, disposition (none/quarantine/reject), SPF_result, DKIM_result, aligned_dkim, aligned_spf, etc., plus a foreign key to AggregateReport ([EasyDMARC Blog | Understanding DMARC reports](https://easydmarc.com/blog/understanding-dmarc-reports/#:~:text=,Number%20of%20messages%20sent)).
- **ForensicReport**: stores forensic report details, e.g., original_message_id, source_ip, timestamp, failing_auth (SPF/DKIM), headers (maybe JSON), and a reference to Domain or AggregateReport if useful.
Using SQLAlchemys ORM, we can define these as Python classes and create a database schema. FastAPI can use dependency injection to provide a DB session to path operations. When new reports are parsed, the data is inserted: for aggregate, create an AggregateReport entry then bulk insert its AggregateRecord children. Proper indexing (e.g., index on source_ip or domain) will be set to optimize queries like finding all emails from a specific source.
### Configuration Wizard & Initial Setup
DMARQ includes a **web-based configuration wizard** to simplify first-time setup. This wizard will run when the app is first launched (or if a setup flag in the DB is not set). The purpose of the wizard is to collect essential configuration values from the user in a UI, so they dont have to manually edit environment files. Key settings captured might include:
- **IMAP Credentials**: Mail server, port, username, and password (or an app password) for the mailbox that will receive DMARC reports. This is needed for the backend to start polling.
- **Domain(s) to Monitor**: The primary domain (or domains) for which DMARC reports will be collected. The wizard can prompt for the domain name and possibly suggest adding the necessary DNS records (DMARC, SPF, DKIM) if not already in place.
- **Alerting Preferences**: An option to configure alerts (e.g., email for critical issues). In MVP this might be skipped or minimal, but the structure allows setting up later.
- **Cloudflare API (Optional)**: If the user wants the app to manage DNS via Cloudflare, the wizard can ask for Cloudflare API token and Zone ID for the domain.
- **Admin User**: If no user exists yet, the wizard will also create the first admin account (or prompt to register one via the normal signup flow).
This wizard is implemented on the frontend as a series of forms (discussed later in Frontend section). On the backend, an endpoint (e.g. `/api/setup`) will accept the configuration payload and save it: likely storing to a **Config** table or to environment variables. Storing config in DB (encrypted where sensitive, e.g., IMAP password) allows the backend to retrieve it at runtime (or we inject it via Pydantic settings model). The wizard will only be accessible until setup is completed (after which it is disabled or requires admin rights to re-run, for changing settings).
Notably, these config values can also be **seeded via environment variables** for those who deploy via Docker and want to skip the wizard. For example, if `DMARQ_IMAP_HOST`, `DMARQ_IMAP_USER`, etc., are provided, the app could auto-create the config and mark setup as done. This dual approach (wizard UI or env vars) makes onboarding flexible.
### Modular Alerting Integration (Apprise)
To keep users informed of important events (like DMARC failures from new sources, or a domain moving to enforcement), DMARQ plans to send notifications. The design is modular to accommodate various notification channels. We prepare integration with **Apprise**, a powerful notification library that supports dozens of services (email, Slack, Teams, SMS, etc.) through a simple API ([caronc/apprise: Apprise - Push Notifications that work with just about ...](https://github.com/caronc/apprise#:~:text=caronc%2Fapprise%3A%20Apprise%20,as%3A%20Telegram%2C%20Discord%2C%20Slack%2C)).
In the backend, an `alerts` module will define functions to send alerts. For MVP, we might implement a basic email alert (using SMTP) for critical issues, but structure the code such that adding a new channel is easy. With Apprise, for example, we can configure it by adding user-supplied URLs (each URL could represent a destination, like an email address, Slack webhook, etc.). The code can load these from config and call `apprise.notify()` with a message. Because Apprise supports *“almost all of the most popular notification services”* in a unified way ([caronc/apprise: Apprise - Push Notifications that work with just about ...](https://github.com/caronc/apprise#:~:text=caronc%2Fapprise%3A%20Apprise%20,as%3A%20Telegram%2C%20Discord%2C%20Slack%2C)), DMARQ users could later choose their preferred methods without the platform having to implement each from scratch.
The modular design means the alerting system is loosely coupled. For instance, whenever a new aggregate report is processed, a function can evaluate conditions (e.g., any DMARC failure from an unknown source? compliance rate dropped below a threshold?) and if so, trigger an alert via the alerts module. Since this is optional, if no alert config is provided, the system can quietly skip it.
### Cloudflare DNS Management (Optional)
Many organizations host their DNS with Cloudflare. DMARQ provides optional **Cloudflare integration** to help manage and monitor DNS records related to email authentication. If enabled (API credentials provided), the backend can use Cloudflare's API to perform tasks such as:
- **DNS Record Retrieval**: On the dashboard's DNS health section, instead of relying on a generic DNS lookup, the backend can directly fetch the DNS records (TXT, MX, etc.) for the domain via Cloudflare API. This can verify the current SPF, DKIM (public keys via selector records), DMARC, MX, and even BIMI records. The results let the UI show a "health check" e.g., ✅ if a record exists and is correctly formatted, or ❌ if missing or misconfigured.
- **DMARC Policy Updates**: The platform could allow the user to update their DMARC record from the UI. For example, after achieving a high compliance rate, the user might want to change policy from `p=none` to `p=quarantine` or `p=reject`. Through the Cloudflare API, DMARQ can programmatically modify the TXT record. (All such actions would be manual triggers by the user in the UI, with confirmation.)
- **BIMI and Others**: If the user has a BIMI record (brand logo), or needs to add one, the integration could help create that DNS entry as well. Since brand protection is related, having an interface for these records is useful.
This Cloudflare support essentially turns DMARQ into a mini DNS management tool focused on email auth records, streamlining the DMARC deployment journey. It remains optional if not used, the DNS health check can fall back to direct DNS queries (using `dnspython` library, for example) to still provide record info.
Under the hood, the Cloudflare integration might use the official Cloudflare Python library or direct REST calls. It will be abstracted in a `cloudflare.py` module that the rest of the app can call (e.g., `cloudflare.get_txt_record(domain, name="_dmarc")`). The user's API token is stored securely (in env or DB, marked secret).
## Frontend Implementation (Integrated Approach)
DMARQ uses an **integrated frontend architecture** with FastAPI's built-in Jinja2 templating system, enhanced with modern web technologies. This approach simplifies deployment and improves performance by eliminating the API boundary between frontend and backend. The UI is built with:
1. **Jinja2 Templates**: For server-side HTML rendering directly from FastAPI
2. **Tailwind CSS**: For utility-first styling that aligns with the DMARQ brand palette
3. **shadcn/ui Components**: Adapted from React to work with server-side rendering
4. **HTMX**: For AJAX functionality without writing complex JavaScript
5. **Alpine.js**: For enhanced interactivity and state management in the browser
6. **Chart.js**: For data visualization components throughout the dashboard
This integrated approach offers several advantages:
- Simplified deployment (single container instead of separate frontend/backend)
- Reduced complexity (no need for API contracts between frontend/backend)
- Improved initial page load performance through server-side rendering
- SEO benefits from server-rendered content
- Progressive enhancement for better accessibility
### Dashboard UI Features
The DMARQ dashboard provides an at-a-glance view of email authentication status and recent issues. Key sections of the dashboard include:
- **DMARC Compliance Rate:** A prominent metric showing the percentage of emails passing DMARC (both SPF and/or DKIM aligned) out of total emails. This displays as a large percentage number with a circular gauge visualization. Compliance rate is crucial to track progress e.g., *"98% compliance"* is often the threshold to move to a stricter policy ([Best Practices: Advancing Your DMARC policy - dmarcian](https://dmarcian.com/advancing-dmarc-policy/#:~:text=that%20these%20domains%20match,mark)).
Using Chart.js, we render a line chart showing compliance rate over time (trendline per day/week). Alpine.js manages the date range selector, allowing users to switch between time periods (last 7 days, 30 days, etc.) with the chart updating dynamically. If 100% is not reached, a note indicates how many messages failed and need attention.
- **Policy Enforcement Trends:** This section visualizes how the domain's DMARC policy and enforcement have evolved. A timeline chart created with Chart.js shows the proportion of emails that were quarantined/rejected over time. As compliance improves, organizations typically move to stricter enforcement. DMARQ also displays markers indicating when policy changed from `none → quarantine → reject`.
The chart is rendered server-side initially, with Alpine.js handling interactions like tooltips and data filtering. A complementary bar chart shows how many spoofed emails were blocked per month, demonstrating the value of proper DMARC enforcement.
- **DNS Record Health Check:** A panel that lists the essential DNS records for email authentication:
- **SPF:** Check if a valid SPF TXT record exists for the domain
- **DKIM:** List the DKIM selectors in use from aggregate reports
- **DMARC:** Show the domain's DMARC record and key tags (p= policy, rua, ruf, pct, etc)
- **MX:** Show whether MX records exist and are properly configured
- **BIMI:** Check for a BIMI record and whether it points to a valid SVG certificate
Each record is displayed with its actual value and a status icon (✅/❌). With Cloudflare integration enabled, HTMX powers interactive "Update" buttons next to each record, which can trigger server-side modals for editing DNS entries.
- **Alerts Summary:** A section highlighting recent alerts or important notices, implemented as a list of the last N alerts with severity icons (info/warning/critical). Clicking an alert uses HTMX to load more details without a full page reload. The alerts are server-rendered initially, with new alerts fetched periodically using HTMX's polling capabilities.
- **Forensic Report Drilldown:** For detailed investigation, the UI offers a forensic report view implemented as a dynamic data table. Users can filter reports by date, source IP, or sending source through form controls enhanced with Alpine.js for instant filtering. Each entry shows information like the source IP, sending domain, failure reasons, and disposition.
The detailed view of each forensic report is loaded on-demand via HTMX triggers, preventing the need to load all details at once. For advanced users, a toggle shows full email headers when needed.
All dashboard components are built as reusable Jinja2 macros or includes, ensuring consistency throughout the application. The layout uses Tailwind's grid and flex utilities for responsiveness, automatically adapting to different screen sizes.
### Onboarding Configuration Wizard UI
The frontend includes an **onboarding wizard** that runs on first use, implemented as a multi-step form:
1. **Welcome Step:** Introduces DMARQ and outlines the setup process
2. **User Account Setup:** Collects email and password for the first admin user
3. **Domain & Email Setup:** Form fields for the domain to monitor and IMAP credentials
4. **Optional Services:** Configuration for Cloudflare API and alerting notifications
5. **Completion:** Summary of settings and initial data fetching
The wizard is built with Jinja2 templates styled using Tailwind CSS classes and shadcn/ui components (adapted for server rendering). Form validation happens both client-side (using Alpine.js for immediate feedback) and server-side (for security). Progress through the wizard is maintained in server-side session state, allowing users to resume setup if interrupted.
Each step includes validation with helpful error messages. For example, when testing IMAP credentials, an HTMX request verifies the connection and provides immediate feedback without a full page reload. Upon completion, configurations are saved to the database and the user is redirected to the main dashboard.
### Theming and Branding in the UI
The DMARQ frontend strongly reflects the brand identity:
- **Color Scheme:** The Tailwind configuration extends the default theme with DMARQ's brand colors: deep blue `#1A237E` as the primary color, vibrant teal `#00ACC1` as the secondary/accent color, and bright orange `#FF7043` for warnings and alerts. Light gray `#F5F5F5` serves as the background for panels, while dark gray `#212121` provides contrast for text.
- **Typography:** The application loads **Montserrat** for headers and **Open Sans** for body text through a combination of @font-face declarations and Tailwind's fontFamily configuration. This ensures consistent typography across all pages.
- **Layout and Navigation:** The application features a responsive layout with a top navigation bar displaying the DMARQ logo and main navigation links. The sidebar (on larger screens) provides context-sensitive navigation based on the current section. All UI elements follow shadcn/ui design patterns, adapted to work with Jinja2 templates instead of React components.
- **Charts and Graphics:** Chart.js visualizations are styled to match the theme, using the brand colors for consistency. The charts are rendered server-side for the initial view, with Alpine.js handling interactive features like tooltips, zooming, and filtering.
By implementing these design elements through Jinja2 templates and Tailwind CSS, DMARQ maintains a cohesive visual identity while benefiting from server-side rendering performance.
## Deployment and Project Structure
To make it easy to run the entire platform, DMARQ provides a Docker-based deployment. We use **Docker Compose** to define all required services and ensure they work together out of the box. The repository is organized clearly with separate directories for backend, frontend, and configuration:
- **backend/** FastAPI application code. This includes submodules like `auth/`, `dmarc/` (for parsing logic), `models/` (SQLAlchemy models), `routes/` (APIRouters for various endpoints such as auth, reports, etc.), and `services/` (e.g., IMAP polling service, alert service). An `app.py` (or `main.py`) at the root creates the FastAPI app, includes routers, and possibly sets up a startup event to kick off the background IMAP polling (or schedules it).
- **frontend/** Jinja2 templates and static assets. This includes Tailwind CSS configuration, shadcn/ui components adapted for server-side rendering, and JavaScript files for HTMX and Alpine.js interactivity. The templates are organized into directories for pages (e.g., `dashboard.html`, `wizard.html`) and partials (e.g., `header.html`, `footer.html`).
- **config/** Configuration and deployment files. This includes `docker-compose.yml` to orchestrate containers, Dockerfiles for the backend, an `.env.example` file documenting environment variables (and possibly a `.env` that can be used for local dev). It may also contain a script or instructions for initial setup (like a shell script to run migrations, create a default admin, etc., although we rely on the wizard for admin creation).
- **README.md** A detailed README in the root explains how to set up and run DMARQ. It will cover prerequisites (Docker installed), how to configure environment (e.g., providing IMAP credentials in `.env` or using the wizard), and how to bring the stack up. It also outlines the project structure for developers, and points to docs or wiki if more info.
- **tests/** Both backend and frontend tests (could be split into `backend/tests` and `frontend/tests`). This ensures the project includes automated tests for critical functionality.
With this structure, **Docker Compose** can define three main services:
1. **db**: The PostgreSQL database. In `docker-compose.yml`, this uses the official postgres image, with environment for password, and a volume for data persistence.
2. **backend**: The FastAPI app image. We create a Dockerfile in `backend/` that starts from a Python base image, installs dependencies (from a `requirements.txt` or `pyproject.toml`), copies the FastAPI app code, and runs Uvicorn (or Hypercorn) to serve the app (for example: `uvicorn app:app --host 0.0.0.0 --port 8000`). This container would link to `db` for database access (the DB URL set via env such as `DATABASE_URL=postgresql://...`).
3. **frontend**: The static assets served by the backend. The backend serves the Jinja2 templates and static files directly, eliminating the need for a separate frontend container.
The **compose setup** enables anyone to do `docker-compose up -d` and have the system running at `http://localhost` (frontend and API). We will ensure CORS is allowed from the frontend origin in FastAPI settings (or if served under same domain, configure nginx to proxy API requests to backend, e.g., prefix `/api/`).
### Running and Deployment Considerations
After containers are up, the user would typically access the app on the configured host (say `app.dmarq.org`). The first thing they see is the setup wizard (if not configured), or the login page otherwise. We will provide a **setup script** or instructions to initialize the database (running migrations if any, though for MVP we might use SQLAlchemy to create tables on first run automatically). FastAPI can be set to create DB tables at startup by using SQLAlchemys `create_all()` in an event handler if not using Alembic yet.
For production deployment beyond dev Docker, the projects modular design allows scaling: the backend can be scaled to multiple instances (stateless except the background job which we might eventually offload to a separate worker to avoid duplicate polling), and perhaps a separate worker container for IMAP polling could be introduced if needed. The database is external and could be managed by a cloud provider.
### CI/CD and GitHub Preparation
The repository will be prepared for GitHub with CI in mind:
- A GitHub Actions workflow (YAML) could be included to run backend tests (`pytest`) and frontend tests (`npm test`) on each pull request, ensuring quality.
- The README will encourage contributions and explain the stack.
- Documentation in-code (docstrings and possibly a docs/ directory with more detailed usage or API info) can be included for developers.
By splitting code cleanly and using Docker, the project is easy to set up for anyone reviewing the code on GitHub. The folder structure and documentation reflect a professional, open-source-ready project, where one can run the entire stack or even deploy it to a cloud (for instance, using services like Heroku or Fly.io with minor adjustments, or Kubernetes if scaling up, etc.).
## Testing and Development Practices
Building a robust platform requires testing and good development standards from the start. DMARQs MVP will include:
### Backend Unit Tests (Pytest)
We will write **unit and integration tests** for the FastAPI backend using **pytest**. Important parts to test include:
- **DMARC Parsing Logic:** Provide sample DMARC aggregate report XML files (and zipped variants) in tests to ensure our parsing function or the parsedmarc integration correctly extracts data. For example, test that a known XML yields the expected number of records, and that edge cases (like multiple DKIM identifiers, or a report with a policy override) are handled.
- **IMAP Fetching:** This can be tested by mocking the IMAP server. Using Pythons `imaplib` we might simulate a mailbox with a test email. Alternatively, abstract the email retrieval in a function that we can feed with a prepared email file. The test would verify that an email with an attached report results in correct DB entries after processing.
- **API Endpoints:** Using FastAPIs TestClient, we can simulate requests to the API. For auth, test that a user can register and login (ensuring FastAPI Users is configured properly). For data endpoints, we may create a fake report in the database and test that the GET endpoint (e.g., `/api/reports/summary`) returns the correct JSON (compliance rate, etc.). Also test security, e.g., that protected endpoints return 401 for anonymous requests.
- **Alerting Module:** Use monkeypatch or dependency override to test that when a certain condition is met, the alert function is called. We might fake the Apprise call to just record that a notification would have been sent.
- **Database Models:** If using an in-memory SQLite for testing (SQLAlchemy can connect to sqlite:///:memory: for speed), we test that `create_all` works and basic CRUD on models functions as expected.
Pytest fixtures can set up a temporary database, perhaps using `sqlite` for quick tests, or a PostgreSQL test container if we want to mimic real environment. We ensure tests are isolated (each test either uses a transaction rollback or a new schema).
### Frontend Testing (Jinja2 and HTMX)
For the Jinja2-based frontend, we use **pytest** and **selenium** for testing:
- **Template Tests:** Test that Jinja2 templates render correctly given context data. For instance, a `ComplianceRateCard` template that takes a percentage should render that number and perhaps an appropriate color (green if ~100%, etc.). We simulate different values and assert the HTML output.
- **Dashboard Page Tests:** Using selenium, render the Dashboard page (with maybe a mock context providing sample data). Ensure that all major sections appear (e.g., "DMARC Compliance Rate" text, an element showing a "%" value, etc.). If there are child components for charts, we might mock the chart library for simplicity or ensure it renders a canvas element.
- **Wizard Flow Tests:** Simulate the multi-step wizard. We can mimic user filling the forms e.g., fill domain and IMAP fields, click next and then verify that the next step appears. Also test validation: e.g., leave a required field empty and assert that an error message shows and it doesnt advance. For the final submission, wed mock the backend response and ensure the app handles success (redirect to login or dashboard).
These tests help catch regressions as we develop. We include running `pytest` as part of CI.
### Code Quality and Documentation
Throughout development, we emphasize **clean code structure**:
- The Python backend code will follow PEP8 style, and we can include linters (flake8/black) configuration. Complex logic (like parsing) will be broken into smaller functions or classes (e.g., a `DMARCReportParser` class to encapsulate parsing functions, which can be unit-tested independently).
- The Jinja2 templates should be organized into logical components and macros. Avoid large monolithic templates; instead, separate e.g., `DnsHealthCard`, `AlertsList`, `ForensicTable` etc. This not only makes it easier to maintain but also easier to test each piece.
- We will add docstrings and comments in critical sections. For instance, the function that polls IMAP will have a comment explaining the IMAP UID tracking if used, or how often it runs.
- The **README** will document how to run tests, how to run formatting tools, etc. It will also have a high-level overview of the system.
### Extensibility and Future-Proofing
Even as an MVP, DMARQ is designed with **extensibility in mind**:
- New features such as additional alerting methods, support for multiple domains or domains owned by different users, or integration with other email security protocols can be added with minimal refactoring. For example, adding another authentication method (SSO or OAuth) is possible because FastAPI Users supports multiple authentication backends if needed.
- The use of standards (FastAPI, SQLAlchemy, Jinja2) means a broad community support and familiarity, making it easier for others to contribute or for the project to grow.
- By modularizing the backend (each major feature in its own module/router), a developer can navigate the codebase easily. The same goes for frontend: clear separation of concerns (e.g., wizard vs dashboard vs shared components).
- Logging is implemented on the backend to trace the processing of reports. If something fails in parsing an email, it logs an error with details (but without dumping sensitive info). This will greatly help in debugging issues in production.
- Security considerations: storing IMAP credentials securely (if in DB, encrypt them using a key), using HTTPS in production, and making sure secrets (like JWT secret, encryption keys) come from environment vars and are not hardcoded.
By following these practices, we ensure that the MVP is not a throwaway prototype, but a solid foundation that can be built upon for a full-fledged DMARC monitoring service.
## Conclusion
The **DMARQ** MVP as described provides a comprehensive end-to-end solution for DMARC report monitoring and analysis. We have a powerful FastAPI backend handling authentication, data ingestion (IMAP fetching of DMARC reports) and parsing per industry standards (RFC 7489 for aggregate reports and RFC 6591 for forensic reports), with data stored in a structured way on PostgreSQL. The frontend offers a modern, responsive dashboard inspired by top industry solutions, displaying critical metrics like DMARC compliance rates and enforcement progress, and guiding users through setup and issue resolution. The platform is containerized via Docker Compose for easy deployment and follows best practices in testing and code organization, making it maintainable and ready for open-source collaboration.
By adhering to the brands guidelines (colors, typography) and focusing on clean UI/UX (with Tailwind, HTMX, and Alpine.js), DMARQ will not only function effectively but also deliver a polished experience. In summary, this MVP achieves the goal of a self-service DMARC monitoring tool **DMARQ** that helps organizations improve their email security posture with clarity and confidence.
**Sources:**
- FastAPI Users documentation (features and JWT auth setup) ([GitHub - fastapi-users/fastapi-users: Ready-to-use and customizable users management for FastAPI](https://github.com/fastapi-users/fastapi-users#:~:text=Add%20quickly%20a%20registration%20and,customizable%20and%20adaptable%20as%20possible)) ([Full example - FastAPI Users](https://fastapi-users.github.io/fastapi-users/10.1/configuration/full-example/#:~:text=app.include_router%28%20fastapi_users.get_auth_router%28auth_backend%29%2C%20prefix%3D,%29%20app.include_router))
- Parsedmarc library open source DMARC report parser (supports IMAP, aggregate & forensic) ([parsedmarc documentation - Open source DMARC report analyzer and visualizer — parsedmarc 8.18.1 documentation](https://domainaware.github.io/parsedmarc/#:~:text=,standard%20aggregate%2Frua%20reports))
- EasyDMARC blog DMARC aggregate report contents (XML fields and frequency) ([EasyDMARC Blog | Understanding DMARC reports](https://easydmarc.com/blog/understanding-dmarc-reports/#:~:text=DMARC%20aggregate%20reports%20are%20XML,sensitive%20information%20about%20email%20messages))
- EasyDMARC blog DMARC failure (forensic) reports overview ([EasyDMARC Blog | Understanding DMARC reports](https://easydmarc.com/blog/understanding-dmarc-reports/#:~:text=Failure%20reports%20go%20to%20the,sources%20that%20need%20further%20configuration))
- Dmarcian guidelines 98%+ compliance rate recommended before policy enforcement ([Best Practices: Advancing Your DMARC policy - dmarcian](https://dmarcian.com/advancing-dmarc-policy/#:~:text=that%20these%20domains%20match,mark))
- Bouncebuster DMARC tools review EasyDMARC noted for user-friendly dashboard ([Top 10 DMARC Monitoring Tools 2025 Bouncebuster Blog](https://blog.bouncebuster.io/top-10-dmarc-monitoring-tools-2025/#:~:text=%2A%20EasyDMARC%3A%20User,blacklist%20tracking%2C%20and%20Safe%20SPF))
- ShadCN/UI introduction open-source Tailwind React components for accessible design