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) %} + +{% 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='') %} + +{% 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) %} +
+ + {{ caller() }} +
+
+{% 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. +

+
+ + + +
+
+
+
+ + +
+

Theme Colors

+
+
primary
+
secondary
+
accent
+
neutral
+
base-100
+
base-200
+
base-300
+
info
+
success
+
warning
+
error
+
+
+ + +
+

Buttons

+
+
+

Button Variants

+
+ + + + + + + +
+ +

Button States

+
+ + + + +
+ +

Button Sizes

+
+ + + + +
+
+
+
+ + +
+

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

Forms

+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+
+ + +
+
+
+
+
+ + +
+

Tables

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailRoleStatusActions
1John Doejohn@example.comAdminActive +
+ + +
+
2Jane Smithjane@example.comUserPending +
+ + +
+
3Robert Johnsonrobert@example.comUserInactive +
+ + +
+
+
+
+
+
+ + +
+

Badges and Tags

+
+
+

Badge Variants

+
+ Neutral + Primary + Secondary + Accent + Info + Success + Warning + Error +
+ +

Badge Sizes

+
+ Extra Small + Small + Normal + Large +
+
+
+
+ + +
+

Modals

+
+
+ + + + + +
+
+
+ + +
+

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

+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+{% 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 %} +
+ + +
+ +
+
+

{{ 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 %} +
+ + +
+
+ {% 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() %} + + + {% 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() %} + + + {% 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() %} + + + {% 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 - - - - - - - - - - - - - - - - - - - - - -
- -
- - -
-
- - - -
DMARQ
-
- -
- - - - -
-
DMARQ v0.1.0
-
DMARC Monitoring Platform
-
-
- - -
- -
-
- -
- -
- - -
-

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

- -
-
- - -
- -
- -
-
-

Total Domains

-
- -
-
-
-
0
-

Active domains being monitored

-
-
- - -
-
-

Emails Analyzed

-
- -
-
-
-
0
-

Total emails processed

-
-
- - -
-
-

Pass Rate

-
- -
-
-
-
0%
-

Overall DMARC compliance

-
-
- - -
-
-

Reports Processed

-
- -
-
-
-
0
-

DMARC reports received

-
-
-
- - -
-
-
-

Domain Compliance

- -
-
-
-
- - - - - - - - - - - - - - -
DomainEmailsPass RateFailedReportsActions
-
-
-
+ {% 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 -

- -
- - - -
- -
-
-
-
-
+ {% endcall %} + {% call card_content() %} +
+
0
+

DMARC reports received

- - -
-
-
-
-

Domain Management

- -
-

View and manage domains with DMARC reports

-
-
-

Loading domains...

- -
-
-
- - - - - -
-
-
-

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() %} +
+
+ + +
+
+ + +
+
+ + +
+
+ {% 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() %} + + {% 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 %} +
+ + +
+ {% 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 %} +
+
+ +
+ + + +
+ +
+ + + +
+ +
+ + +
+
+ {% 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 %} + +

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 %} +
+ +
+ +
+
+ {% 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 +

+ +
+ +
+
+
+ + +
+ +
+
+ +
+ + + + + +
+ +
+ +
+
+ {% 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: