diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..3efbc77
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,28 @@
+# DMARQ Environment Variables
+#
+# Copy this file to .env and fill in the values for your environment
+# Example: cp .env.example .env
+
+# Application Settings
+PROJECT_NAME="DMARQ"
+SECRET_KEY="CHANGE_THIS_TO_A_RANDOM_SECRET_IN_PRODUCTION"
+
+# Database
+DATABASE_URL="sqlite:///./dmarq.db"
+
+# IMAP Settings for DMARC Report Retrieval
+IMAP_SERVER="mail.example.com" # Required for IMAP polling
+IMAP_PORT=993 # Default for SSL
+IMAP_USERNAME="dmarc@example.com"
+IMAP_PASSWORD="your_imap_password"
+
+# CORS Origins (comma separated)
+BACKEND_CORS_ORIGINS="http://localhost:3000,http://localhost:5173"
+
+# Admin User (first-time setup)
+FIRST_SUPERUSER="admin@example.com"
+FIRST_SUPERUSER_PASSWORD="adminpassword"
+
+# Optional Cloudflare API Integration (for Milestone 8)
+# CLOUDFLARE_API_TOKEN="your_cloudflare_api_token"
+# CLOUDFLARE_ZONE_ID="your_cloudflare_zone_id"
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 0a19790..958d4c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -172,3 +172,17 @@ cython_debug/
# PyPI configuration file
.pypirc
+
+/node_modules
+
+/backend/node_modules
+
+# Tailwind build artifacts
+/backend/app/static/css/output.css
+/backend/app/static/css/output.css.map
+
+# NPM log files and cache
+/backend/npm-debug.log*
+/backend/yarn-debug.log*
+/backend/yarn-error.log*
+/backend/.npm/
\ No newline at end of file
diff --git a/backend/Dockerfile b/backend/Dockerfile
index e37f14b..6ea033f 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -2,23 +2,24 @@ FROM python:3.10-slim
WORKDIR /app
-# Install required system dependencies
+ # Install required system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
+ curl \
+ ca-certificates \
&& rm -rf /var/lib/apt/lists/*
-# Copy requirements file
-COPY requirements.txt .
+# Remove Tailwind & DaisyUI build; using CDN links instead
-# Install Python dependencies
+# Copy Python requirements and install
+COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
-# Copy application code
+# Copy application code including templates and static assets
COPY . .
-# Expose the port the app runs on
+# Expose application port
EXPOSE 8080
-# Command to run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
\ No newline at end of file
diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py
index 1595880..3673dae 100644
--- a/backend/app/api/api_v1/api.py
+++ b/backend/app/api/api_v1/api.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter
-from app.api.api_v1.endpoints import domains, health, reports, setup
+from app.api.api_v1.endpoints import domains, health, reports, setup, imap
api_router = APIRouter()
@@ -8,4 +8,5 @@ api_router = APIRouter()
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"])
\ No newline at end of file
+api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
+api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
\ No newline at end of file
diff --git a/backend/app/api/api_v1/endpoints/domains.py b/backend/app/api/api_v1/endpoints/domains.py
index 39534d1..b60e384 100644
--- a/backend/app/api/api_v1/endpoints/domains.py
+++ b/backend/app/api/api_v1/endpoints/domains.py
@@ -1,5 +1,6 @@
from typing import List, Optional, Dict, Any
-from fastapi import APIRouter, HTTPException, status
+from datetime import datetime, timedelta
+from fastapi import APIRouter, HTTPException, status, Path, Query
from pydantic import BaseModel
from app.services.report_store import ReportStore
@@ -18,6 +19,55 @@ class DomainResponse(DomainBase):
emails_count: int = 0
compliance_rate: float = 0.0
+class DomainStatsResponse(BaseModel):
+ """Domain statistics for the domain details page"""
+ complianceRate: float
+ totalEmails: int
+ failedEmails: int
+ reportCount: int
+
+class DNSRecordResponse(BaseModel):
+ """DNS record information for a domain"""
+ dmarc: bool
+ dmarcRecord: Optional[str] = None
+ spf: bool
+ spfRecord: Optional[str] = None
+ dkim: bool
+ dkimSelectors: Optional[str] = None
+
+class TimelinePoint(BaseModel):
+ """Data point for compliance timeline"""
+ date: str
+ compliance_rate: float
+
+class ReportEntry(BaseModel):
+ """Summary of a DMARC report"""
+ id: str
+ org_name: str
+ begin_date: int
+ end_date: int
+ total_emails: int
+ pass_rate: float
+ policy: str
+
+class SourceEntry(BaseModel):
+ """Summary of a sending source"""
+ ip: str
+ count: int
+ spf: str
+ dkim: str
+ dmarc: str
+ disposition: str
+
+class DomainReportsResponse(BaseModel):
+ """Domain reports with compliance timeline"""
+ reports: List[ReportEntry]
+ compliance_timeline: List[TimelinePoint]
+
+class DomainSourcesResponse(BaseModel):
+ """Domain sending sources"""
+ sources: List[SourceEntry]
+
class DomainSummaryResponse(BaseModel):
"""Domain summary for dashboard"""
total_domains: int
@@ -119,4 +169,150 @@ async def read_domain(domain_name: str):
reports_count=summary.get("reports_processed", 0),
emails_count=summary.get("total_count", 0),
compliance_rate=summary.get("compliance_rate", 0.0)
+ )
+
+# New endpoints for domain details page
+
+@router.get("/{domain_id}/stats", response_model=DomainStatsResponse)
+async def get_domain_stats(domain_id: str = Path(..., title="The domain ID or name")):
+ """
+ Get detailed statistics for a specific domain
+ """
+ store = ReportStore.get_instance()
+ domains = store.get_domains()
+
+ # For Milestone 1, domain_id is simply the domain name
+ if domain_id not in domains:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Domain not found",
+ )
+
+ summary = store.get_domain_summary(domain_id)
+ total_count = summary.get("total_count", 0)
+ passed_count = summary.get("passed_count", 0)
+ failed_count = total_count - passed_count
+ compliance_rate = summary.get("compliance_rate", 0.0)
+ reports_processed = summary.get("reports_processed", 0)
+
+ return DomainStatsResponse(
+ complianceRate=compliance_rate,
+ totalEmails=total_count,
+ failedEmails=failed_count,
+ reportCount=reports_processed
+ )
+
+@router.get("/{domain_id}/dns", response_model=DNSRecordResponse)
+async def get_domain_dns_records(domain_id: str = Path(..., title="The domain ID or name")):
+ """
+ Get DNS records for a specific domain. For Milestone 1,
+ this returns mock data since DNS integration is part of a future milestone.
+ """
+ store = ReportStore.get_instance()
+ domains = store.get_domains()
+
+ if domain_id not in domains:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Domain not found",
+ )
+
+ # For Milestone 1, return mock DNS record data
+ # In a future milestone, this will be replaced with actual DNS lookups
+ return DNSRecordResponse(
+ dmarc=True,
+ dmarcRecord="v=DMARC1; p=none; rua=mailto:dmarc@example.com; ruf=mailto:forensic@example.com; pct=100",
+ spf=True,
+ spfRecord="v=spf1 include:_spf.google.com include:spf.protection.outlook.com -all",
+ dkim=True,
+ dkimSelectors="selector1, selector2"
+ )
+
+@router.get("/{domain_id}/reports", response_model=DomainReportsResponse)
+async def get_domain_reports(
+ domain_id: str = Path(..., title="The domain ID or name"),
+ limit: int = Query(10, title="Maximum number of reports to return")
+):
+ """
+ Get recent DMARC reports for a specific domain, along with compliance timeline
+ """
+ store = ReportStore.get_instance()
+ domains = store.get_domains()
+
+ if domain_id not in domains:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Domain not found",
+ )
+
+ # Get reports for this domain
+ reports = store.get_domain_reports(domain_id, limit=limit)
+
+ # Generate report entries
+ report_entries = []
+ for report in reports:
+ report_entries.append(ReportEntry(
+ id=report.get("report_id", "unknown"),
+ org_name=report.get("org_name", "Unknown Organization"),
+ begin_date=report.get("begin_date", 0),
+ end_date=report.get("end_date", 0),
+ total_emails=report.get("total_count", 0),
+ pass_rate=report.get("pass_rate", 0.0),
+ policy=report.get("policy", "none")
+ ))
+
+ # Generate compliance timeline (last 30 days)
+ timeline = []
+ for i in range(30, 0, -1):
+ date = datetime.now() - timedelta(days=i)
+ date_str = date.strftime("%Y-%m-%d")
+
+ # For Milestone 1, generate some mock data with variation
+ # In future milestone, this will use actual historical data
+ import random
+ compliance_rate = random.uniform(80, 100)
+
+ timeline.append(TimelinePoint(
+ date=date_str,
+ compliance_rate=round(compliance_rate, 1)
+ ))
+
+ return DomainReportsResponse(
+ reports=report_entries,
+ compliance_timeline=timeline
+ )
+
+@router.get("/{domain_id}/sources", response_model=DomainSourcesResponse)
+async def get_domain_sources(
+ domain_id: str = Path(..., title="The domain ID or name"),
+ days: int = Query(30, title="Number of days to look back")
+):
+ """
+ Get sending sources for a specific domain
+ """
+ store = ReportStore.get_instance()
+ domains = store.get_domains()
+
+ if domain_id not in domains:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Domain not found",
+ )
+
+ # Get sending sources for this domain
+ sources = store.get_domain_sources(domain_id, days=days)
+
+ source_entries = []
+ for source in sources:
+ source_entries.append(SourceEntry(
+ ip=source.get("source_ip", "unknown"),
+ count=source.get("count", 0),
+ spf=source.get("spf_result", "unknown"),
+ dkim=source.get("dkim_result", "unknown"),
+ dmarc="pass" if source.get("spf_result") == "pass" or source.get("dkim_result") == "pass" else "fail",
+ disposition=source.get("disposition", "none")
+ ))
+
+ return DomainSourcesResponse(
+ sources=source_entries
)
\ No newline at end of file
diff --git a/backend/app/api/api_v1/endpoints/imap.py b/backend/app/api/api_v1/endpoints/imap.py
new file mode 100644
index 0000000..a75b15d
--- /dev/null
+++ b/backend/app/api/api_v1/endpoints/imap.py
@@ -0,0 +1,65 @@
+from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
+from typing import Dict, Any
+from datetime import datetime
+
+from app.services.imap_client import IMAPClient
+
+router = APIRouter()
+
+@router.post("/test-connection")
+async def test_imap_connection(
+ server: str = None,
+ port: int = 993,
+ username: str = None,
+ password: str = None
+) -> Dict[str, Any]:
+ """
+ Test connection to an IMAP server
+ """
+ imap_client = IMAPClient(
+ server=server,
+ port=port,
+ username=username,
+ password=password
+ )
+
+ success, message = imap_client.test_connection()
+
+ return {
+ "success": success,
+ "message": message,
+ "timestamp": datetime.now().isoformat()
+ }
+
+
+@router.post("/fetch-reports")
+async def fetch_imap_reports(
+ background_tasks: BackgroundTasks,
+ days: int = 7,
+ delete_emails: bool = False
+) -> Dict[str, Any]:
+ """
+ Fetch DMARC reports from the configured IMAP mailbox
+ """
+ imap_client = IMAPClient(delete_emails=delete_emails)
+
+ # Run in background if it might take a while
+ if days > 14:
+ background_tasks.add_task(imap_client.fetch_reports, days)
+ return {
+ "success": True,
+ "message": f"Background task started to fetch {days} days of reports",
+ "timestamp": datetime.now().isoformat()
+ }
+
+ # Otherwise run immediately
+ results = imap_client.fetch_reports(days=days)
+
+ return {
+ "success": results["success"],
+ "processed_emails": results["processed"],
+ "reports_found": results["reports_found"],
+ "new_domains": results["new_domains"],
+ "errors": results["errors"] if "errors" in results and results["errors"] else None,
+ "timestamp": datetime.now().isoformat()
+ }
\ No newline at end of file
diff --git a/backend/app/main.py b/backend/app/main.py
index 433ccaf..f66d248 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,15 +1,67 @@
-from fastapi import FastAPI, Request
+from fastapi import FastAPI, Request, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
import os
+import asyncio
+import logging
+from datetime import datetime
from app.api.api_v1.api import api_router
from app.core.config import get_settings
+from app.services.imap_client import IMAPClient
+from app.services.report_store import ReportStore
+
+# Set up logging
+logger = logging.getLogger(__name__)
settings = get_settings()
+# Global variables for background task management
+background_task = None
+last_check_time = None
+
+
+async def scheduled_imap_polling():
+ """Background task for periodically checking IMAP for new DMARC reports"""
+ global last_check_time
+
+ try:
+ # How often to check for emails (in seconds)
+ check_interval = 3600 # Default: 1 hour
+
+ while True:
+ logger.info("Starting scheduled IMAP polling for DMARC reports")
+
+ try:
+ # Create IMAP client and fetch reports
+ imap_client = IMAPClient(delete_emails=False)
+ results = imap_client.fetch_reports(days=9999)
+
+ # Update last check time
+ last_check_time = datetime.now()
+
+ if results["success"]:
+ logger.info(f"IMAP polling completed: {results['processed']} emails processed, "
+ f"{results['reports_found']} reports found")
+
+ # If new domains were found, log them
+ if results["new_domains"]:
+ logger.info(f"New domains found: {', '.join(results['new_domains'])}")
+
+ else:
+ logger.error(f"IMAP polling failed: {results.get('error', 'Unknown error')}")
+
+ except Exception as e:
+ logger.error(f"Error in IMAP polling task: {str(e)}")
+
+ # Wait for the next check interval
+ await asyncio.sleep(check_interval)
+
+ except asyncio.CancelledError:
+ logger.info("IMAP polling task cancelled")
+
def create_app() -> FastAPI:
"""Create and configure the FastAPI application"""
@@ -35,6 +87,31 @@ def create_app() -> FastAPI:
# Mount static files directory
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
+ # Set up event handlers for startup and shutdown
+ @app.on_event("startup")
+ async def startup_event():
+ """Initialize background tasks on application startup"""
+ global background_task
+
+ # Check if IMAP credentials are configured
+ if all([settings.IMAP_SERVER, settings.IMAP_USERNAME, settings.IMAP_PASSWORD]):
+ logger.info("Starting IMAP polling background task")
+ background_task = asyncio.create_task(scheduled_imap_polling())
+ else:
+ logger.warning("IMAP credentials not fully configured, polling disabled")
+
+ @app.on_event("shutdown")
+ async def shutdown_event():
+ """Clean up background tasks on application shutdown"""
+ global background_task
+ if background_task:
+ logger.info("Cancelling IMAP polling background task")
+ background_task.cancel()
+ try:
+ await background_task
+ except asyncio.CancelledError:
+ pass
+
return app
@@ -46,33 +123,110 @@ 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}
- )
+async def dashboard(request: Request):
+ return templates.TemplateResponse("index.html", {"request": request})
-# Frontend routes that should return the SPA template
+# Individual page routes
@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"""
+async def dashboard(request: Request):
return templates.TemplateResponse(
- "index.html",
- {"request": request, "app_name": settings.PROJECT_NAME}
+ "dashboard.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"""
+@app.get("/login", response_class=HTMLResponse)
+async def login(request: Request):
return templates.TemplateResponse(
- "index.html",
- {"request": request, "app_name": settings.PROJECT_NAME}
- )
\ No newline at end of file
+ "login.html", {"request": request, "app_name": settings.PROJECT_NAME}
+ )
+
+@app.get("/setup", response_class=HTMLResponse)
+async def setup(request: Request):
+ return templates.TemplateResponse(
+ "setup.html", {"request": request, "app_name": settings.PROJECT_NAME}
+ )
+
+@app.get("/domains", response_class=HTMLResponse)
+async def domains(request: Request):
+ return templates.TemplateResponse("domains.html", {"request": request})
+
+@app.get("/domain/{domain_id}", response_class=HTMLResponse)
+async def domain_details(request: Request, domain_id: str):
+ """View detailed reports for a specific domain"""
+ store = ReportStore.get_instance()
+ domains = store.get_domains()
+
+ if domain_id not in domains:
+ # Domain not found, redirect to domains list
+ return templates.TemplateResponse(
+ "domains.html",
+ {"request": request, "error": f"Domain {domain_id} not found"}
+ )
+
+ domain_summary = store.get_domain_summary(domain_id)
+
+ return templates.TemplateResponse(
+ "domain_details.html",
+ {
+ "request": request,
+ "domain_id": domain_id,
+ "domain": {
+ "name": domain_id,
+ "description": "", # Add description if available
+ "policy": domain_summary.get("policy", "unknown")
+ }
+ }
+ )
+
+@app.get("/reports", response_class=HTMLResponse)
+async def reports(request: Request):
+ return templates.TemplateResponse("reports.html", {"request": request})
+
+@app.get("/settings", response_class=HTMLResponse)
+async def settings_page(request: Request):
+ return templates.TemplateResponse("settings.html", {"request": request})
+
+@app.get("/upload", response_class=HTMLResponse)
+async def upload_page(request: Request):
+ return templates.TemplateResponse("upload.html", {"request": request})
+
+
+# API endpoint to manually trigger IMAP polling
+@app.post("/api/v1/admin/trigger-poll")
+async def trigger_imap_poll(background_tasks: BackgroundTasks):
+ """Manually trigger IMAP polling (admin only)"""
+ global last_check_time
+
+ try:
+ # Create IMAP client and fetch reports
+ imap_client = IMAPClient(delete_emails=False)
+ results = imap_client.fetch_reports(days=7)
+
+ # Update last check time
+ last_check_time = datetime.now()
+
+ return {
+ "success": results["success"],
+ "timestamp": last_check_time.isoformat(),
+ "processed": results["processed"],
+ "reports_found": results["reports_found"],
+ "new_domains": results["new_domains"]
+ }
+ except Exception as e:
+ logger.error(f"Error triggering IMAP poll: {str(e)}")
+ return {
+ "success": False,
+ "error": str(e)
+ }
+
+
+# API endpoint to check status of IMAP polling
+@app.get("/api/v1/admin/poll-status")
+async def get_poll_status():
+ """Get the status of IMAP polling"""
+ global last_check_time
+
+ return {
+ "is_running": background_task is not None and not background_task.done(),
+ "last_check": last_check_time.isoformat() if last_check_time else None
+ }
\ No newline at end of file
diff --git a/backend/app/services/imap_client.py b/backend/app/services/imap_client.py
new file mode 100644
index 0000000..9515aba
--- /dev/null
+++ b/backend/app/services/imap_client.py
@@ -0,0 +1,327 @@
+import imaplib
+import email
+import os
+import logging
+import tempfile
+from email.header import decode_header
+from typing import List, Dict, Any, Optional, Tuple
+from datetime import datetime, timedelta
+
+from app.core.config import get_settings
+from app.services.dmarc_parser import DMARCParser
+from app.services.report_store import ReportStore
+
+# Setup logger
+logger = logging.getLogger(__name__)
+
+class IMAPClient:
+ """
+ Client for retrieving DMARC reports from an IMAP mailbox
+ """
+
+ def __init__(self,
+ server: str = None,
+ port: int = None,
+ username: str = None,
+ password: str = None,
+ delete_emails: bool = False):
+ """
+ Initialize the IMAP client with credentials
+
+ Args:
+ server: IMAP server hostname (if None, uses settings)
+ port: IMAP server port (if None, uses settings)
+ username: IMAP username (if None, uses settings)
+ password: IMAP password (if None, uses settings)
+ delete_emails: Whether to delete emails after processing (default: False)
+ """
+ settings = get_settings()
+
+ self.server = server or settings.IMAP_SERVER
+ self.port = port or settings.IMAP_PORT
+ self.username = username or settings.IMAP_USERNAME
+ self.password = password or settings.IMAP_PASSWORD
+ self.delete_emails = delete_emails
+
+ self.report_store = ReportStore.get_instance()
+
+ if not all([self.server, self.username, self.password]):
+ logger.warning("IMAP credentials not fully configured")
+
+ def test_connection(self) -> Tuple[bool, str]:
+ """
+ Test the IMAP connection
+
+ Returns:
+ Tuple of (success, message)
+ """
+ if not all([self.server, self.username, self.password]):
+ return False, "IMAP credentials not fully configured"
+
+ try:
+ # Create IMAP4 connection
+ mail = imaplib.IMAP4_SSL(self.server, self.port)
+ # Login
+ mail.login(self.username, self.password)
+ # List mailboxes
+ mail.list()
+ # Select inbox
+ mail.select('INBOX')
+ # Logout
+ mail.logout()
+ return True, "Connection successful"
+ except Exception as e:
+ logger.error(f"IMAP connection test failed: {str(e)}")
+ return False, f"Connection failed: {str(e)}"
+
+ def fetch_reports(self, days: int = 7) -> Dict[str, Any]:
+ """
+ Fetch and process DMARC reports from the configured mailbox
+
+ Args:
+ days: Number of days to look back for emails
+
+ Returns:
+ Dictionary with stats about processing results
+ """
+ if not all([self.server, self.username, self.password]):
+ logger.error("IMAP credentials not fully configured")
+ return {
+ "success": False,
+ "error": "IMAP credentials not configured",
+ "processed": 0
+ }
+
+ stats = {
+ "success": True,
+ "processed": 0,
+ "reports_found": 0,
+ "new_domains": [],
+ "errors": []
+ }
+
+ try:
+ # Connect to the mail server
+ mail = imaplib.IMAP4_SSL(self.server, self.port)
+ mail.login(self.username, self.password)
+ mail.select('INBOX')
+
+ # Calculate the date range for search
+ date_since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y")
+
+ # Search for all emails containing possible DMARC reports
+ search_criteria = f'(SINCE {date_since})'
+ status, data = mail.search(None, search_criteria)
+
+ if status != 'OK':
+ logger.error("Error searching mailbox")
+ stats["success"] = False
+ stats["error"] = "Error searching mailbox"
+ mail.logout()
+ return stats
+
+ # Get list of email IDs
+ email_ids = data[0].split()
+
+ # Track domains before processing to identify new ones
+ domains_before = set(self.report_store.get_domains())
+
+ # Process each email
+ for email_id in email_ids:
+ try:
+ # Fetch the email
+ status, msg_data = mail.fetch(email_id, '(RFC822)')
+
+ if status != 'OK':
+ logger.error(f"Error fetching email ID {email_id}")
+ continue
+
+ # Parse the email
+ raw_email = msg_data[0][1]
+ msg = email.message_from_bytes(raw_email)
+
+ # Check if this email might contain DMARC reports
+ if self._is_dmarc_report_email(msg):
+ # Process attachments
+ reports_found = self._process_attachments(msg)
+ stats["reports_found"] += reports_found
+
+ # Mark email as read
+ mail.store(email_id, '+FLAGS', '\\Seen')
+
+ # Delete email if configured
+ if self.delete_emails:
+ mail.store(email_id, '+FLAGS', '\\Deleted')
+
+ stats["processed"] += 1
+ except Exception as e:
+ error_msg = f"Error processing email ID {email_id}: {str(e)}"
+ logger.error(error_msg)
+ stats["errors"].append(error_msg)
+
+ # Actually remove emails marked for deletion
+ if self.delete_emails:
+ mail.expunge()
+
+ # Logout
+ mail.logout()
+
+ # Identify new domains
+ domains_after = set(self.report_store.get_domains())
+ stats["new_domains"] = list(domains_after - domains_before)
+
+ return stats
+
+ except Exception as e:
+ logger.error(f"Error fetching DMARC reports: {str(e)}")
+ return {
+ "success": False,
+ "error": f"Error connecting to mailbox: {str(e)}",
+ "processed": 0
+ }
+
+ def _is_dmarc_report_email(self, msg: email.message.Message) -> bool:
+ """
+ Check if an email likely contains DMARC reports
+
+ Args:
+ msg: Email message object
+
+ Returns:
+ True if the email is likely a DMARC report, False otherwise
+ """
+ # Get email subject
+ subject = ""
+ if "Subject" in msg:
+ subject = self._decode_email_header(msg["Subject"])
+
+ # Get email from
+ from_addr = ""
+ if "From" in msg:
+ from_addr = self._decode_email_header(msg["From"])
+
+ # Common keywords in DMARC report emails
+ dmarc_keywords = [
+ "dmarc", "aggregate", "report", "rua",
+ "authentication", "domain", "failure"
+ ]
+
+ # Common senders of DMARC reports
+ dmarc_senders = [
+ "noreply@", "dmarc-noreply@", "postmaster@",
+ "microsoft.com", "google.com", "yahoo.com",
+ "hotmail.com", "outlook.com", "mail.ru"
+ ]
+
+ # Check if subject contains DMARC keywords
+ if any(keyword in subject.lower() for keyword in dmarc_keywords):
+ return True
+
+ # Check if sender matches common DMARC report senders
+ if any(sender in from_addr.lower() for sender in dmarc_senders):
+ return True
+
+ # Check for attachments with typical DMARC report filenames
+ return self._has_dmarc_attachments(msg)
+
+ def _decode_email_header(self, header: str) -> str:
+ """
+ Decode an email header that might contain non-ASCII characters
+
+ Args:
+ header: Email header string
+
+ Returns:
+ Decoded header text
+ """
+ decoded_parts = []
+ for text, encoding in decode_header(header):
+ if isinstance(text, bytes):
+ if encoding:
+ decoded_parts.append(text.decode(encoding or 'utf-8', errors='replace'))
+ else:
+ decoded_parts.append(text.decode('utf-8', errors='replace'))
+ else:
+ decoded_parts.append(text)
+
+ return " ".join(decoded_parts)
+
+ def _has_dmarc_attachments(self, msg: email.message.Message) -> bool:
+ """
+ Check if the email has attachments that might be DMARC reports
+
+ Args:
+ msg: Email message object
+
+ Returns:
+ True if the email has potential DMARC report attachments
+ """
+ for part in msg.walk():
+ content_disposition = part.get_content_disposition()
+ if content_disposition == 'attachment':
+ filename = part.get_filename()
+ if filename:
+ # Decode filename if needed
+ filename = self._decode_email_header(filename)
+
+ # Check file extension
+ if (filename.lower().endswith('.xml') or
+ filename.lower().endswith('.zip') or
+ filename.lower().endswith('.gz') or
+ filename.lower().endswith('.gzip')):
+ return True
+
+ # Check content type
+ content_type = part.get_content_type()
+ if (content_type == 'application/zip' or
+ content_type == 'application/gzip' or
+ content_type == 'application/x-gzip' or
+ content_type == 'application/xml' or
+ content_type == 'text/xml'):
+ return True
+
+ return False
+
+ def _process_attachments(self, msg: email.message.Message) -> int:
+ """
+ Process email attachments that might be DMARC reports
+
+ Args:
+ msg: Email message object
+
+ Returns:
+ Number of DMARC reports found and processed
+ """
+ reports_found = 0
+
+ for part in msg.walk():
+ content_disposition = part.get_content_disposition()
+
+ if content_disposition == 'attachment':
+ filename = part.get_filename()
+ if filename:
+ # Decode filename if needed
+ filename = self._decode_email_header(filename)
+
+ # Check if it's a likely DMARC report file
+ if (filename.lower().endswith('.xml') or
+ filename.lower().endswith('.zip') or
+ filename.lower().endswith('.gz') or
+ filename.lower().endswith('.gzip')):
+
+ try:
+ # Get attachment content
+ content = part.get_payload(decode=True)
+
+ # Parse the DMARC report
+ report = DMARCParser.parse_file(content, filename)
+
+ # Add the report to the store
+ self.report_store.add_report(report)
+
+ reports_found += 1
+ logger.info(f"Successfully processed DMARC report: {filename}")
+ except Exception as e:
+ logger.error(f"Error processing attachment {filename}: {str(e)}")
+
+ return reports_found
\ No newline at end of file
diff --git a/backend/app/services/report_store.py b/backend/app/services/report_store.py
index 66f3036..701b351 100644
--- a/backend/app/services/report_store.py
+++ b/backend/app/services/report_store.py
@@ -1,5 +1,6 @@
-from typing import Dict, List, Any
+from typing import Dict, List, Any, Optional
import threading
+from datetime import datetime, timedelta
class ReportStore:
"""
@@ -29,6 +30,8 @@ class ReportStore:
self.domain_reports: Dict[str, List[Dict[str, Any]]] = {}
# Domain -> summary stats
self.domain_summary: Dict[str, Dict[str, Any]] = {}
+ # Domain -> sources (sending IPs)
+ self.domain_sources: Dict[str, Dict[str, Dict[str, Any]]] = {}
def add_report(self, report: Dict[str, Any]) -> None:
"""
@@ -48,6 +51,7 @@ class ReportStore:
"failed_count": 0,
"reports_processed": 0,
}
+ self.domain_sources[domain] = {}
# Add the new report
self.domain_reports[domain].append(report)
@@ -59,6 +63,28 @@ class ReportStore:
self.domain_summary[domain]["failed_count"] += summary.get("failed_count", 0)
self.domain_summary[domain]["reports_processed"] += 1
+ # Set policy from the latest report
+ if "policy" in report:
+ self.domain_summary[domain]["policy"] = report["policy"]
+
+ # Update source data
+ report_records = report.get("records", [])
+ for record in report_records:
+ source_ip = record.get("source_ip", "unknown")
+ if source_ip not in self.domain_sources[domain]:
+ self.domain_sources[domain][source_ip] = {
+ "count": 0,
+ "spf_result": "unknown",
+ "dkim_result": "unknown",
+ "disposition": "none"
+ }
+
+ # Update source counts and results
+ self.domain_sources[domain][source_ip]["count"] += record.get("count", 0)
+ self.domain_sources[domain][source_ip]["spf_result"] = record.get("spf", "unknown")
+ self.domain_sources[domain][source_ip]["dkim_result"] = record.get("dkim", "unknown")
+ self.domain_sources[domain][source_ip]["disposition"] = record.get("disposition", "none")
+
# Calculate compliance rate (percentage of passing emails)
if self.domain_summary[domain]["total_count"] > 0:
pass_rate = (
@@ -96,14 +122,71 @@ class ReportStore:
"""
return self.domain_summary
- def get_domain_reports(self, domain: str) -> List[Dict[str, Any]]:
+ def get_domain_reports(self, domain: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
"""
Get all reports for a domain
Args:
domain: Domain name
+ limit: Optional limit on number of reports to return
Returns:
List of reports or empty list if domain not found
"""
- return self.domain_reports.get(domain, [])
\ No newline at end of file
+ reports = self.domain_reports.get(domain, [])
+
+ # Sort reports by date (most recent first)
+ sorted_reports = sorted(
+ reports,
+ key=lambda r: r.get("end_date", 0),
+ reverse=True
+ )
+
+ # Calculate pass rate for each report
+ for report in sorted_reports:
+ total = report.get("summary", {}).get("total_count", 0)
+ passed = report.get("summary", {}).get("passed_count", 0)
+ if total > 0:
+ report["pass_rate"] = round((passed / total) * 100, 1)
+ else:
+ report["pass_rate"] = 0
+
+ # Apply limit if provided
+ if limit is not None:
+ return sorted_reports[:limit]
+ return sorted_reports
+
+ def get_domain_sources(self, domain: str, days: int = 30) -> List[Dict[str, Any]]:
+ """
+ Get sending sources for a domain
+
+ Args:
+ domain: Domain name
+ days: Number of days to look back
+
+ Returns:
+ List of source entries or empty list if domain not found
+ """
+ if domain not in self.domain_sources:
+ return []
+
+ # For Milestone 1, we don't filter by date
+ # In a future milestone, we'll add date-based filtering
+ sources = []
+ for ip, data in self.domain_sources[domain].items():
+ source_entry = {
+ "source_ip": ip,
+ **data
+ }
+ sources.append(source_entry)
+
+ # Sort sources by count (highest first)
+ return sorted(sources, key=lambda s: s["count"], reverse=True)
+
+ def clear(self) -> None:
+ """
+ Clear all data in the store
+ """
+ self.domain_reports = {}
+ self.domain_summary = {}
+ self.domain_sources = {}
\ No newline at end of file
diff --git a/backend/app/static/css/styles.css b/backend/app/static/css/styles.css
index 832b0f7..b1c9f28 100644
--- a/backend/app/static/css/styles.css
+++ b/backend/app/static/css/styles.css
@@ -1,161 +1,249 @@
/**
- * DMARQ Integrated Frontend Styles
+ * DMARQ Frontend Styles with DaisyUI
*/
-/* Setup Wizard Styles */
+/* Base style imports */
+@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Open+Sans:wght@400;500;600&display=swap');
+
+/* Include Tailwind layers */
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* Load DaisyUI plugins */
+@plugin "./daisyui.js";
+@plugin "./daisyui-theme.js";
+
+/* Base styles */
+body {
+ font-family: 'Open Sans', sans-serif;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ font-family: 'Montserrat', sans-serif;
+}
+
+/* Theme initialization */
+:root {
+ color-scheme: light;
+ --rounded-box: 0.5rem;
+ --rounded-btn: 0.25rem;
+ --rounded-badge: 1.9rem;
+}
+
+.dark {
+ color-scheme: dark;
+}
+
+/* Custom component overrides and extensions */
+
+/* Updated color scheme based on DMARQ branding */
+:root {
+ --color-primary: #1A237E; /* Deep Blue */
+ --color-secondary: #00ACC1; /* Vibrant Teal */
+ --color-accent: #FF7043; /* Bright Orange */
+ --color-neutral-light: #F5F5F5; /* Light Gray */
+ --color-neutral-dark: #212121; /* Dark Gray */
+}
+
+/* Apply colors to DaisyUI components */
+.btn-primary {
+ background-color: var(--color-primary);
+ color: var(--color-neutral-light);
+}
+
+.btn-secondary {
+ background-color: var(--color-secondary);
+ color: var(--color-neutral-light);
+}
+
+.alert-success {
+ background-color: var(--color-secondary);
+ color: var(--color-neutral-light);
+}
+
+.alert-error {
+ background-color: var(--color-accent);
+ color: var(--color-neutral-light);
+}
+
+.card {
+ background-color: var(--color-neutral-light);
+ color: var(--color-neutral-dark);
+}
+
+/* Override default DaisyUI styles */
+.bg-primary {
+ background-color: var(--color-primary) !important;
+}
+
+.text-primary {
+ color: var(--color-primary) !important;
+}
+
+.bg-secondary {
+ background-color: var(--color-secondary) !important;
+}
+
+.text-secondary {
+ color: var(--color-secondary) !important;
+}
+
+.bg-accent {
+ background-color: var(--color-accent) !important;
+}
+
+.text-accent {
+ color: var(--color-accent) !important;
+}
+
+/* Sidebar */
+.sidebar {
+ background-color: #FFFFFF; /* bg-base-100 */
+ position: fixed;
+ left: 0;
+ top: 0;
+ overflow-y: auto;
+ border-right: 1px solid #E0E0E0; /* border-r border-base-300 */
+ width: 16rem;
+ height: 100vh;
+ z-index: 10;
+}
+
+.sidebar-header {
+ @apply p-4 border-b border-base-300;
+}
+
+.sidebar-nav {
+ @apply space-y-1 p-2;
+}
+
+.sidebar-nav-item {
+ @apply flex items-center gap-2 px-3 py-2 text-sm font-medium transition-colors hover:bg-base-200;
+}
+
+.sidebar-nav-item-active {
+ @apply bg-primary text-primary-content;
+}
+
+/* Dashboard stats */
+.stat-card {
+ @apply card bg-base-100 shadow-sm;
+}
+
+.stat-title {
+ @apply text-sm font-medium opacity-70;
+}
+
+.stat-value {
+ @apply text-3xl font-bold;
+}
+
+.stat-description {
+ @apply text-xs opacity-70;
+}
+
+/* Setup Wizard Progress */
.setup-progress {
- display: flex;
- justify-content: space-between;
- margin-bottom: 2rem;
- position: relative;
+ @apply flex items-center mb-8;
}
.setup-step {
- position: relative;
- padding: 0.5rem 1rem;
- background-color: #f3f4f6;
- border-radius: 0.25rem;
- font-weight: 500;
- z-index: 1;
+ @apply flex items-center;
}
-.setup-step.active {
- background-color: #3b82f6;
- color: white;
+.setup-step-circle {
+ @apply w-8 h-8 rounded-full bg-base-300 flex items-center justify-center opacity-70;
}
-.setup-step.completed {
- background-color: #10b981;
- color: white;
+.setup-step-active .setup-step-circle {
+ @apply bg-primary text-primary-content;
}
-.setup-progress:after {
- content: '';
- position: absolute;
- top: 50%;
- left: 0;
- right: 0;
- height: 2px;
- background-color: #e5e7eb;
- z-index: 0;
+.setup-step-completed .setup-step-circle {
+ @apply bg-success text-success-content;
}
-/* Form Styles */
-input, select, textarea {
- width: 100%;
- padding: 0.5rem;
- border: 1px solid #d1d5db;
- border-radius: 0.25rem;
- margin-bottom: 1rem;
+.setup-step-line {
+ @apply w-16 h-1 bg-base-300;
}
-button {
- background-color: #3b82f6;
- color: white;
- border: none;
- padding: 0.5rem 1rem;
- border-radius: 0.25rem;
- cursor: pointer;
- font-weight: 500;
+.setup-step-completed .setup-step-line {
+ @apply bg-success;
}
-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 */
+/* Responsive layout */
@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;
- }
+ .sidebar {
+ display: none;
+ }
+
+ .main-content {
+ margin-left: 0;
+ }
+}
+
+.main-content {
+ margin-left: 16rem; /* ml-64 */
+ padding: 1.5rem; /* p-6 */
+}
+
+/* Dashboard specific styles */
+.dashboard-header {
+ @apply flex items-center justify-between mb-6;
+}
+
+.dashboard-title {
+ @apply text-3xl font-bold;
+}
+
+/* Dashboard statistics styling */
+.stats-grid {
+ @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6;
+}
+
+/* Domain Compliance table */
+.domain-compliance-table {
+ @apply table table-zebra w-full rounded-lg overflow-hidden;
+}
+
+/* IMAP status indicator */
+.status-indicator {
+ @apply inline-flex items-center gap-2;
+}
+
+.status-dot {
+ @apply w-2.5 h-2.5 rounded-full;
+}
+
+.status-dot.running {
+ @apply bg-success;
+}
+
+.status-dot.stopped {
+ @apply bg-error;
+}
+
+/* Rating status colors */
+.pass-rate-high {
+ @apply badge badge-success;
+}
+
+.pass-rate-medium {
+ @apply badge badge-warning;
+}
+
+.pass-rate-low {
+ @apply badge badge-error;
+}
+
+/* Toggle theme button */
+.toggle-theme {
+ @apply btn btn-ghost btn-circle;
+}
+
+/* Dashboard chart containers */
+.chart-container {
+ height: 16rem; /* h-64 */
+ @apply w-full;
}
\ No newline at end of file
diff --git a/backend/app/static/img/logo_dark.avif b/backend/app/static/img/logo_dark.avif
new file mode 100644
index 0000000..19d4a7f
Binary files /dev/null and b/backend/app/static/img/logo_dark.avif differ
diff --git a/backend/app/static/img/logo_dark.png b/backend/app/static/img/logo_dark.png
new file mode 100644
index 0000000..0912f5c
Binary files /dev/null and b/backend/app/static/img/logo_dark.png differ
diff --git a/backend/app/static/img/logo_dark.webp b/backend/app/static/img/logo_dark.webp
new file mode 100644
index 0000000..9a95c29
Binary files /dev/null and b/backend/app/static/img/logo_dark.webp differ
diff --git a/backend/app/static/img/logo_light.avif b/backend/app/static/img/logo_light.avif
new file mode 100644
index 0000000..cc94f5e
Binary files /dev/null and b/backend/app/static/img/logo_light.avif differ
diff --git a/backend/app/static/img/logo_light.png b/backend/app/static/img/logo_light.png
new file mode 100644
index 0000000..56bfa8f
Binary files /dev/null and b/backend/app/static/img/logo_light.png differ
diff --git a/backend/app/static/img/logo_light.webp b/backend/app/static/img/logo_light.webp
new file mode 100644
index 0000000..1e2b67b
Binary files /dev/null and b/backend/app/static/img/logo_light.webp differ
diff --git a/backend/app/static/img/logotype_horizontal_dark.avif b/backend/app/static/img/logotype_horizontal_dark.avif
new file mode 100644
index 0000000..92acf23
Binary files /dev/null and b/backend/app/static/img/logotype_horizontal_dark.avif differ
diff --git a/backend/app/static/img/logotype_horizontal_dark.png b/backend/app/static/img/logotype_horizontal_dark.png
new file mode 100644
index 0000000..ee4b034
Binary files /dev/null and b/backend/app/static/img/logotype_horizontal_dark.png differ
diff --git a/backend/app/static/img/logotype_horizontal_dark.webp b/backend/app/static/img/logotype_horizontal_dark.webp
new file mode 100644
index 0000000..43a37bc
Binary files /dev/null and b/backend/app/static/img/logotype_horizontal_dark.webp differ
diff --git a/backend/app/static/img/logotype_horizontal_light.avif b/backend/app/static/img/logotype_horizontal_light.avif
new file mode 100644
index 0000000..794d7b8
Binary files /dev/null and b/backend/app/static/img/logotype_horizontal_light.avif differ
diff --git a/backend/app/static/img/logotype_horizontal_light.png b/backend/app/static/img/logotype_horizontal_light.png
new file mode 100644
index 0000000..7e023fa
Binary files /dev/null and b/backend/app/static/img/logotype_horizontal_light.png differ
diff --git a/backend/app/static/img/logotype_horizontal_light.webp b/backend/app/static/img/logotype_horizontal_light.webp
new file mode 100644
index 0000000..8332081
Binary files /dev/null and b/backend/app/static/img/logotype_horizontal_light.webp differ
diff --git a/backend/app/static/img/logotype_vertical_dark.avif b/backend/app/static/img/logotype_vertical_dark.avif
new file mode 100644
index 0000000..6e213e9
Binary files /dev/null and b/backend/app/static/img/logotype_vertical_dark.avif differ
diff --git a/backend/app/static/img/logotype_vertical_dark.png b/backend/app/static/img/logotype_vertical_dark.png
new file mode 100644
index 0000000..1ae167d
Binary files /dev/null and b/backend/app/static/img/logotype_vertical_dark.png differ
diff --git a/backend/app/static/img/logotype_vertical_dark.webp b/backend/app/static/img/logotype_vertical_dark.webp
new file mode 100644
index 0000000..a0bbfff
Binary files /dev/null and b/backend/app/static/img/logotype_vertical_dark.webp differ
diff --git a/backend/app/static/img/logotype_vertical_light.avif b/backend/app/static/img/logotype_vertical_light.avif
new file mode 100644
index 0000000..996b8bd
Binary files /dev/null and b/backend/app/static/img/logotype_vertical_light.avif differ
diff --git a/backend/app/static/img/logotype_vertical_light.png b/backend/app/static/img/logotype_vertical_light.png
new file mode 100644
index 0000000..69299b1
Binary files /dev/null and b/backend/app/static/img/logotype_vertical_light.png differ
diff --git a/backend/app/static/img/logotype_vertical_light.webp b/backend/app/static/img/logotype_vertical_light.webp
new file mode 100644
index 0000000..182a1be
Binary files /dev/null and b/backend/app/static/img/logotype_vertical_light.webp differ
diff --git a/backend/app/static/img/monogram_dark.avif b/backend/app/static/img/monogram_dark.avif
new file mode 100644
index 0000000..8754535
Binary files /dev/null and b/backend/app/static/img/monogram_dark.avif differ
diff --git a/backend/app/static/img/monogram_dark.png b/backend/app/static/img/monogram_dark.png
new file mode 100644
index 0000000..cc5a5c3
Binary files /dev/null and b/backend/app/static/img/monogram_dark.png differ
diff --git a/backend/app/static/img/monogram_dark.webp b/backend/app/static/img/monogram_dark.webp
new file mode 100644
index 0000000..c216c3f
Binary files /dev/null and b/backend/app/static/img/monogram_dark.webp differ
diff --git a/backend/app/static/img/monogram_light.avif b/backend/app/static/img/monogram_light.avif
new file mode 100644
index 0000000..6ebaa3a
Binary files /dev/null and b/backend/app/static/img/monogram_light.avif differ
diff --git a/backend/app/static/img/monogram_light.png b/backend/app/static/img/monogram_light.png
new file mode 100644
index 0000000..25eb865
Binary files /dev/null and b/backend/app/static/img/monogram_light.png differ
diff --git a/backend/app/static/img/monogram_light.webp b/backend/app/static/img/monogram_light.webp
new file mode 100644
index 0000000..b1c3e9b
Binary files /dev/null and b/backend/app/static/img/monogram_light.webp differ
diff --git a/backend/app/templates/components/ui/alert.html b/backend/app/templates/components/ui/alert.html
new file mode 100644
index 0000000..980d7a1
--- /dev/null
+++ b/backend/app/templates/components/ui/alert.html
@@ -0,0 +1,44 @@
+{% macro alert(variant='info', dismissible=False, id='') %}
+
+ {% if variant == 'info' %}
+
+ {% elif variant == 'success' %}
+
+ {% elif variant == 'warning' %}
+
+ {% elif variant == 'error' %}
+
+ {% endif %}
+
+
+ {{ caller() }}
+
+
+ {% if dismissible %}
+
+
+
+ {% endif %}
+
+{% endmacro %}
+
+{% macro alert_title() %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro alert_description() %}
+
+ {{ caller() }}
+
+{% endmacro %}
\ No newline at end of file
diff --git a/backend/app/templates/components/ui/button.html b/backend/app/templates/components/ui/button.html
new file mode 100644
index 0000000..6dcc3a4
--- /dev/null
+++ b/backend/app/templates/components/ui/button.html
@@ -0,0 +1,15 @@
+{% macro button(variant='default', size='md', class='', type='button', disabled=False) %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro button_link(href='#', variant='default', size='md', class='') %}
+
+ {{ caller() }}
+
+{% endmacro %}
\ No newline at end of file
diff --git a/backend/app/templates/components/ui/card.html b/backend/app/templates/components/ui/card.html
new file mode 100644
index 0000000..504e95b
--- /dev/null
+++ b/backend/app/templates/components/ui/card.html
@@ -0,0 +1,35 @@
+{% macro card() %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro card_header() %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro card_title(text='') %}
+
+ {% if text %}{{ text }}{% else %}{{ caller() }}{% endif %}
+
+{% endmacro %}
+
+{% macro card_description(text='') %}
+
+ {% if text %}{{ text }}{% else %}{{ caller() }}{% endif %}
+
+{% endmacro %}
+
+{% macro card_content() %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro card_footer() %}
+
+ {{ caller() }}
+
+{% endmacro %}
\ No newline at end of file
diff --git a/backend/app/templates/components/ui/input.html b/backend/app/templates/components/ui/input.html
new file mode 100644
index 0000000..0329ff9
--- /dev/null
+++ b/backend/app/templates/components/ui/input.html
@@ -0,0 +1,44 @@
+{% macro input(
+ type='text',
+ name='',
+ id='',
+ value='',
+ placeholder='',
+ required=False,
+ disabled=False,
+ readonly=False,
+ class='',
+ min='',
+ max='',
+ step=''
+) %}
+
+{% endmacro %}
+
+{% macro label(for='', required=False, class='') %}
+
+ {{ caller() }}{% if required %} * {% endif %}
+
+{% endmacro %}
+
+{% macro form_group() %}
+
+ {{ caller() }}
+
+{% endmacro %}
\ No newline at end of file
diff --git a/backend/app/templates/components/ui/table.html b/backend/app/templates/components/ui/table.html
new file mode 100644
index 0000000..e42b662
--- /dev/null
+++ b/backend/app/templates/components/ui/table.html
@@ -0,0 +1,37 @@
+{% macro table(class='', zebra=True) %}
+
+{% endmacro %}
+
+{% macro thead() %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro tbody() %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro tr(class='') %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro th(class='') %}
+
+ {{ caller() }}
+
+{% endmacro %}
+
+{% macro td(class='', colspan='', rowspan='') %}
+
+ {{ caller() }}
+
+{% endmacro %}
\ No newline at end of file
diff --git a/backend/app/templates/daisy-demo.html b/backend/app/templates/daisy-demo.html
new file mode 100644
index 0000000..44c0ed3
--- /dev/null
+++ b/backend/app/templates/daisy-demo.html
@@ -0,0 +1,408 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}DMARQ - DaisyUI Components Demo{% endblock %}
+{% block page_title %}DaisyUI Components Demo{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
DaisyUI Components for DMARQ
+
+ This page demonstrates all the available DaisyUI components that can be used in the DMARQ application.
+ All components are styled using the custom DMARQ theme.
+
+
+ Primary
+ Secondary
+ Accent
+
+
+
+
+
+
+
+ Theme Colors
+
+
primary
+
secondary
+
accent
+
neutral
+
base-100
+
base-200
+
base-300
+
info
+
success
+
warning
+
error
+
+
+
+
+
+ Buttons
+
+
+
Button Variants
+
+ Default
+ Neutral
+ Primary
+ Secondary
+ Accent
+ Ghost
+ Link
+
+
+
Button States
+
+ Normal
+ Outline
+ Disabled
+ Loading
+
+
+
Button Sizes
+
+ Extra Small
+ Small
+ Normal
+ Large
+
+
+
+
+
+
+
+ Alerts
+
+
+
+
+
Information
+
This is an informational alert.
+
+
+
+
+
+
Success!
+
Your action was completed successfully.
+
+
+
+
+
+
Warning!
+
This action requires your attention.
+
+
+
+
+
+
Error!
+
There was an error processing your request.
+
+
+
+
+
+
+
+
+
+
+ Tables
+
+
+
+
+
+
+
+
+ Name
+ Email
+ Role
+ Status
+ Actions
+
+
+
+
+
+ 1
+ John Doe
+ john@example.com
+ Admin
+ Active
+
+
+ Edit
+ Delete
+
+
+
+
+ 2
+ Jane Smith
+ jane@example.com
+ User
+ Pending
+
+
+ Edit
+ Delete
+
+
+
+
+ 3
+ Robert Johnson
+ robert@example.com
+ User
+ Inactive
+
+
+ Edit
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+ Badges and Tags
+
+
+
Badge Variants
+
+ Neutral
+ Primary
+ Secondary
+ Accent
+ Info
+ Success
+ Warning
+ Error
+
+
+
Badge Sizes
+
+ Extra Small
+ Small
+ Normal
+ Large
+
+
+
+
+
+
+
+ Modals
+
+
+
+
Open Modal
+
+
+
Hello!
+
This is a modal example using DaisyUI.
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+ Progress
+
+
+
+
Default Progress (75%)
+
+
+
+
Primary Progress (45%)
+
+
+
+
Secondary Progress (60%)
+
+
+
+
Success Progress (90%)
+
+
+
+
Warning Progress (50%)
+
+
+
+
Error Progress (25%)
+
+
+
+
+
+
+
+
+ Tabs
+
+
+
+
+
+
This is the Overview tab content. Tabs are useful for organizing content into different sections.
+
+
+
+
+
This is the Details tab content. You can include any type of content here, including forms, tables, or other components.
+
+
+
+
+
This is the Stats tab content. Tabs help keep your interface clean and organized.
+
+
+
+
+
+
+
+
+ Accordion
+
+
+
+
+
+ What is DMARQ?
+
+
+
DMARQ is a DMARC monitoring tool that helps organizations track and analyze their email authentication compliance.
+
+
+
+
+
+ How does DMARC work?
+
+
+
DMARC (Domain-based Message Authentication, Reporting and Conformance) is an email authentication protocol that builds upon SPF and DKIM to help prevent email spoofing.
+
+
+
+
+
+ What are the benefits of using DMARQ?
+
+
+
DMARQ provides insights into email authentication failures, helps identify legitimate sources that may be failing authentication, and offers recommendations to improve email deliverability and security.
+
+
+
+
+
+
+
+
+ Tooltips
+
+
+
+
+ Top
+
+
+ Bottom
+
+
+ Left
+
+
+ Right
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/backend/app/templates/domain_details.html b/backend/app/templates/domain_details.html
new file mode 100644
index 0000000..a9bb719
--- /dev/null
+++ b/backend/app/templates/domain_details.html
@@ -0,0 +1,490 @@
+{% extends "layouts/base.html" %}
+{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content, card_footer %}
+{% from "components/ui/button.html" import button, button_link %}
+{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %}
+
+{% block title %}DMARQ - {{ domain.name }} Details{% endblock %}
+
+{% block content %}
+
+
+
+ Dashboard
+ /
+ Domains
+ /
+ {{ domain.name }}
+
+
+
+
+
+
+
+
{{ domain.name }}
+
{{ domain.description or "Domain monitored by DMARQ" }}
+
+
+ {% call button(variant="outline", size="sm") %}
+
+ Edit Domain
+ {% endcall %}
+ {% call button(variant="outline", size="sm") %}
+
+ Check DNS
+ {% endcall %}
+
+
+
+
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}DMARC Compliance{% endcall %}
+
+
+ {% endcall %}
+ {% call card_content() %}
+
+
-
+
Emails passing DMARC
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}Total Emails{% endcall %}
+
+
+ {% endcall %}
+ {% call card_content() %}
+
+
-
+
Total emails processed
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}Failed Emails{% endcall %}
+
+
+
+
+ {% endcall %}
+ {% call card_content() %}
+
+
-
+
Emails failing DMARC
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}Reports{% endcall %}
+
+
+ {% endcall %}
+ {% call card_content() %}
+
+
-
+
DMARC reports received
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}Compliance Over Time{% endcall %}
+ {% call card_description() %}
+ DMARC pass rate for the past 30 days
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}DNS Records{% endcall %}
+ {% call card_description() %}
+ Email authentication DNS records for this domain
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+
+
+ DMARC Record
+
+
+
+
-
+
+
+
+ SPF Record
+
+
+
+
-
+
+
+
+ DKIM Selectors
+
+
+
+
-
+
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}Sending Sources{% endcall %}
+
+
+ Last 7 days
+ Last 30 days
+ Last 90 days
+ All time
+
+
+
+
+ {% call card_description() %}
+ IP addresses and servers sending email as this domain
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+ {% call table() %}
+ {% call thead() %}
+ {% call tr() %}
+ {% call th() %}Source IP{% endcall %}
+ {% call th() %}Total Emails{% endcall %}
+ {% call th() %}SPF{% endcall %}
+ {% call th() %}DKIM{% endcall %}
+ {% call th() %}DMARC{% endcall %}
+ {% call th() %}Disposition{% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% call tbody() %}
+
+
+
+ No data available for this time period
+
+
+
+
+ {% call tr() %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+ Pass
+
+
+ Fail
+
+
+
+
+ {% endcall %}
+ {% call td() %}
+
+ Pass
+
+
+ Fail
+
+
+
+
+ {% endcall %}
+ {% call td() %}
+
+ Pass
+
+
+ Fail
+
+ {% endcall %}
+ {% call td() %}
+
+ None
+
+
+ Quarantine
+
+
+ Reject
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}Recent Reports{% endcall %}
+ {% call card_description() %}
+ Latest DMARC reports received for this domain
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+ {% call table() %}
+ {% call thead() %}
+ {% call tr() %}
+ {% call th() %}Date{% endcall %}
+ {% call th() %}Organization{% endcall %}
+ {% call th() %}Emails{% endcall %}
+ {% call th() %}Pass Rate{% endcall %}
+ {% call th() %}Policy{% endcall %}
+ {% call th() %}Actions{% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% call tbody() %}
+
+
+
+ No reports available
+
+
+
+
+ {% call tr() %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+
+
+ {% endcall %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+ View
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+
+{% endblock %}
+
+{% block scripts %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/backend/app/templates/domains.html b/backend/app/templates/domains.html
new file mode 100644
index 0000000..d0dc1e7
--- /dev/null
+++ b/backend/app/templates/domains.html
@@ -0,0 +1,135 @@
+{% extends "layouts/base.html" %}
+{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content, card_footer %}
+{% from "components/ui/button.html" import button, button_link %}
+{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %}
+
+{% block title %}DMARQ - Domains{% endblock %}
+
+{% block content %}
+
+
Domain Management
+
+ {% if error %}
+
+ {% endif %}
+
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}Monitored Domains{% endcall %}
+ {% call button(variant="outline", size="sm") %}
+ + Add Domain
+ {% endcall %}
+
+ {% call card_description() %}
+ Domains currently being monitored for DMARC compliance
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+ {% call table() %}
+ {% call thead() %}
+ {% call tr() %}
+ {% call th() %}Domain{% endcall %}
+ {% call th() %}DMARC Status{% endcall %}
+ {% call th() %}SPF Status{% endcall %}
+ {% call th() %}DKIM Status{% endcall %}
+ {% call th("text-right") %}Actions{% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% call tbody() %}
+
+ {% call tr() %}
+ {% call td(colspan="5", class="text-center") %}
+ No domains found. Add a domain to get started.
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call tr() %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+
+
+
+ {% endcall %}
+ {% call td() %}
+
+
+
+
+ {% endcall %}
+ {% call td() %}
+
+
+
+
+ {% endcall %}
+ {% call td("text-right") %}
+
+
+ Details
+
+ {% call button(variant="outline", size="sm") %}
+ Edit
+ {% endcall %}
+
+ {% endcall %}
+ {% endcall %}
+
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/backend/app/templates/index.html b/backend/app/templates/index.html
index 6b57ddf..aca43e3 100644
--- a/backend/app/templates/index.html
+++ b/backend/app/templates/index.html
@@ -1,866 +1,299 @@
-
-
-
-
-
- DMARQ - DMARC Monitoring
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Open sidebar
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}Pass Rate{% endcall %}
+
-
-
-
-
-
-
-
-
-
-
-
-
-
No DMARC reports have been uploaded yet
-
Upload a report to see statistics and gain insights into your domain's email authentication.
-
- Upload Report
-
-
-
-
-
-
-
-
-
-
-
-
-
0
-
Active domains being monitored
-
-
-
-
-
-
-
Emails Analyzed
-
-
-
-
-
-
0
-
Total emails processed
-
-
-
-
-
-
-
-
0%
-
Overall DMARC compliance
-
-
-
-
-
-
-
Reports Processed
-
-
-
-
-
-
0
-
DMARC reports received
-
-
-
-
-
-
-
-
-
Domain Compliance
-
- Refresh
-
-
-
-
-
-
-
-
- Domain
- Emails
- Pass Rate
- Failed
- Reports
- Actions
-
-
-
-
-
-
-
-
-
+ {% endcall %}
+ {% call card_content() %}
+
+
0%
+
Overall DMARC compliance
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}Reports Processed{% endcall %}
+
-
-
-
-
-
-
Upload DMARC Reports
-
Upload your DMARC aggregate report files (XML, ZIP, or GZIP)
-
-
-
-
-
-
-
-
- Click to upload or drag and drop
-
-
- XML, ZIP, or GZIP files only
-
-
-
-
-
-
-
-
- Upload Report
-
-
-
-
-
-
+ {% endcall %}
+ {% call card_content() %}
+
+
0
+
DMARC reports received
-
-
-
-
-
-
-
Domain Management
-
- Refresh
-
-
-
View and manage domains with DMARC reports
-
-
-
-
-
-
-
-
-
- Back to Summary
-
-
-
-
-
-
-
-
-
-
-
Reports Summary
-
Advanced report visualization and analysis
-
-
-
-
-
-
-
Coming Soon
-
- Advanced report visualization and analysis features will be available in future milestones.
-
-
-
-
-
-
-
-
-
-
-
Settings
-
User preferences and system configuration
-
-
-
-
-
-
-
Coming Soon
-
- User preferences and system configuration will be available in future milestones.
-
-
-
-
-
-
-
+ {% endcall %}
+ {% endcall %}
+
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}Domain Compliance{% endcall %}
+ {% call button(variant="outline", size="sm") %}
+
+ Refresh
+ {% endcall %}
+
+ {% call card_description() %}
+ Overview of domains and their DMARC compliance status
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+ {% call table() %}
+ {% call thead() %}
+ {% call tr() %}
+ {% call th() %}Domain{% endcall %}
+ {% call th() %}Emails{% endcall %}
+ {% call th() %}Pass Rate{% endcall %}
+ {% call th() %}Failed{% endcall %}
+ {% call th() %}Reports{% endcall %}
+ {% call th("text-right") %}Actions{% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% call tbody() %}
+
+
+
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+
+
+
+ {% call card() %}
+ {% call card_content() %}
+
+
+
No DMARC reports have been uploaded yet
+
Upload a report or configure IMAP integration to see statistics and gain insights into your domain's email authentication.
+
+ {% call button_link(href="/upload") %}
+
+ Upload Report
+ {% endcall %}
+ {% call button_link(href="/settings", variant="outline") %}
+
+ Configure IMAP
+ {% endcall %}
+
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}IMAP Integration Status{% endcall %}
+ {% call button_link(href="/api/v1/admin/trigger-poll", variant="outline", size="sm") %}
+
+ Trigger Poll Now
+ {% endcall %}
+
+ {% endcall %}
+ {% call card_content() %}
+
+
+ Status:
+
+
+ Running
+
+
+
+ Last Check:
+ Never
+
+
+ Mailbox:
+ dmarc-reports@hosterra.net
+
+
+ {% endcall %}
+ {% endcall %}
+
+
+{% endblock %}
-
-
-
\ No newline at end of file
+ }
+}
+
+{% endblock %}
\ No newline at end of file
diff --git a/backend/app/templates/layouts/base.html b/backend/app/templates/layouts/base.html
new file mode 100644
index 0000000..9d15ab9
--- /dev/null
+++ b/backend/app/templates/layouts/base.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+ {% block title %}DMARQ - DMARC Monitoring{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+ {% block head %}{% endblock %}
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+ {% block scripts %}{% endblock %}
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/app/templates/reports.html b/backend/app/templates/reports.html
new file mode 100644
index 0000000..17ed501
--- /dev/null
+++ b/backend/app/templates/reports.html
@@ -0,0 +1,219 @@
+{% extends "layouts/base.html" %}
+{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content, card_footer %}
+{% from "components/ui/button.html" import button, button_link %}
+{% from "components/ui/table.html" import table, thead, tbody, tr, th, td %}
+
+{% block title %}DMARQ - Reports{% endblock %}
+
+{% block content %}
+
+
DMARC Reports
+
+
+
+ {% call card() %}
+ {% call card_content() %}
+
+
+ Domain
+
+ All Domains
+
+
+
+
+
+
+ Report Type
+
+ All Types
+ Aggregate (RUA)
+ Forensic (RUF)
+
+
+
+ Date Range
+
+ Last 7 days
+ Last 14 days
+ Last 30 days
+ Last 90 days
+ All time
+
+
+
+ {% call button(variant="outline") %}
+
+ Reset Filters
+ {% endcall %}
+
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+
+ {% call card() %}
+ {% call card_header() %}
+
+ {% call card_title() %}DMARC Reports{% endcall %}
+
+ {% call card_description() %}
+ Showing
reports
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+ {% call table() %}
+ {% call thead() %}
+ {% call tr() %}
+ {% call th() %}Date{% endcall %}
+ {% call th() %}Type{% endcall %}
+ {% call th() %}Domain{% endcall %}
+ {% call th() %}Organization{% endcall %}
+ {% call th() %}Messages{% endcall %}
+ {% call th() %}Pass Rate{% endcall %}
+ {% call th("text-right") %}Actions{% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% call tbody() %}
+
+ {% call tr() %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+
+
+ {% endcall %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+ {% endcall %}
+ {% call td() %}
+
+
+
+ {% endcall %}
+ {% call td("text-right") %}
+ {% call button(variant="outline", size="sm") %}
+ View Details
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+ {% endcall %}
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/backend/app/templates/settings.html b/backend/app/templates/settings.html
new file mode 100644
index 0000000..ca23c70
--- /dev/null
+++ b/backend/app/templates/settings.html
@@ -0,0 +1,336 @@
+{% extends "layouts/base.html" %}
+{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content, card_footer %}
+{% from "components/ui/button.html" import button %}
+{% from "components/ui/alert.html" import alert, alert_title, alert_description %}
+{% from "components/ui/input.html" import input, label, form_group %}
+
+{% block title %}Settings - DMARQ{% endblock %}
+
+{% block page_title %}Settings{% endblock %}
+
+{% block content %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}IMAP Configuration{% endcall %}
+ {% call card_description() %}
+ Configure the IMAP connection to automatically retrieve DMARC reports from your email
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+
+
+ {% call form_group() %}
+ {% call label(for="imap_server", required=True) %}IMAP Server{% endcall %}
+ {{ input(type="text", name="imap_server", id="imap_server", placeholder="mail.example.com", required=True) }}
+ {% endcall %}
+
+ {% call form_group() %}
+ {% call label(for="imap_port", required=True) %}IMAP Port{% endcall %}
+ {{ input(type="number", name="imap_port", id="imap_port", value="993", required=True) }}
+ {% endcall %}
+
+ {% call form_group() %}
+ {% call label(for="imap_ssl") %}Use SSL{% endcall %}
+
+
+ Enable SSL/TLS connection (recommended)
+
+ {% endcall %}
+
+
+
+ {% call form_group() %}
+ {% call label(for="imap_username", required=True) %}IMAP Username{% endcall %}
+ {{ input(type="text", name="imap_username", id="imap_username", placeholder="dmarc-reports@example.com", required=True) }}
+ {% endcall %}
+
+ {% call form_group() %}
+ {% call label(for="imap_password", required=True) %}IMAP Password{% endcall %}
+
+ {{ input(type="password", name="imap_password", id="imap_password", required=True) }}
+
+
+
+
+
+ {% endcall %}
+
+ {% call form_group() %}
+ {% call label(for="polling_interval") %}Polling Interval (minutes){% endcall %}
+ {{ input(type="number", name="polling_interval", id="polling_interval", value="60", min="15", max="1440") }}
+
How often to check for new reports (minimum 15 minutes)
+ {% endcall %}
+
+
+
+
+
+ {% call alert(variant="success") %}
+ {% call alert_title() %}Connection Successful{% endcall %}
+ {% call alert_description() %}
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+ {% call alert(variant="error") %}
+ {% call alert_title() %}Connection Failed{% endcall %}
+ {% call alert_description() %}
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+
+
+ {% call alert(variant="success") %}
+ {% call alert_title() %}Settings Saved{% endcall %}
+ {% call alert_description() %}
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+ {% call alert(variant="error") %}
+ {% call alert_title() %}Save Failed{% endcall %}
+ {% call alert_description() %}
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+
+
+
+
+ Test Connection
+
+
+
+
+
+
+ Testing...
+
+
+
+
+
+ Save Configuration
+
+
+
+
+
+
+ Saving...
+
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}DMARC Policy Management{% endcall %}
+ {% call card_description() %}
+ Configure default DMARC policy settings for newly added domains
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+
+ {% call form_group() %}
+ {% call label(for="default_policy") %}Default DMARC Policy{% endcall %}
+
+ None (monitoring only)
+ Quarantine (send to spam)
+ Reject (block delivery)
+
+
Policy applied to new domains when no specific policy is set
+ {% endcall %}
+
+ {% call form_group() %}
+ {% call label(for="percent") %}Percentage{% endcall %}
+
+
+ 100%
+
+
Percentage of messages to which the DMARC policy is applied
+ {% endcall %}
+
+
+
+
+
+ Save Policy Settings
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/backend/app/templates/upload.html b/backend/app/templates/upload.html
new file mode 100644
index 0000000..7377cc1
--- /dev/null
+++ b/backend/app/templates/upload.html
@@ -0,0 +1,203 @@
+{% extends "layouts/base.html" %}
+{% from "components/ui/card.html" import card, card_header, card_title, card_description, card_content, card_footer %}
+{% from "components/ui/button.html" import button %}
+{% from "components/ui/alert.html" import alert, alert_title, alert_description %}
+
+{% block title %}Upload DMARC Reports - DMARQ{% endblock %}
+
+{% block page_title %}Upload DMARC Reports{% endblock %}
+
+{% block content %}
+
+
+ {% call card() %}
+ {% call card_header() %}
+ {% call card_title() %}Upload DMARC Reports{% endcall %}
+ {% call card_description() %}
+ Upload your DMARC aggregate report files (XML, ZIP, or GZIP)
+ {% endcall %}
+ {% endcall %}
+ {% call card_content() %}
+
+
+
+
+ Click to upload or drag and drop
+
+
+ XML, ZIP, or GZIP files only
+
+
+
+
+
+
+
+
+ {% call alert(variant="success") %}
+ {% call alert_title() %}Upload Successful{% endcall %}
+ {% call alert_description() %}
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+ {% call alert(variant="error") %}
+ {% call alert_title() %}Upload Failed{% endcall %}
+ {% call alert_description() %}
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+ {% call alert(variant="info") %}
+ {% call alert_title() %}Processing{% endcall %}
+ {% call alert_description() %}
+
+
+
+
+
+
Uploading and processing your DMARC report...
+
+ {% endcall %}
+ {% endcall %}
+
+
+
+
+
+
+
+ Upload Report
+
+
+
+
+
+
+ Uploading...
+
+
+
+
+ {% endcall %}
+ {% endcall %}
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index 94543c1..1506468 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -35,6 +35,8 @@ services:
- SECRET_KEY=your_secret_key_change_in_production
- DEBUG=True
- ENVIRONMENT=development
+ # Add NODE_ENV for Tailwind
+ - NODE_ENV=production
networks:
- dmarq-network
ports: