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
+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.