Add domain management, reports, settings, and upload templates

- Implemented domain management page with a list of monitored domains and their DMARC, SPF, and DKIM statuses.
- Created reports page with filtering options for domain, report type, and date range, displaying DMARC reports.
- Developed settings page for IMAP configuration and DMARC policy management, including form validation and feedback.
- Added upload page for DMARC report files with drag-and-drop functionality and file type validation.
- Integrated Alpine.js for interactivity and dynamic data handling across all templates.
This commit is contained in:
Christian Krakau-Louis
2025-04-20 18:38:29 +02:00
parent f910cb0ba4
commit 69f8438a36
48 changed files with 3449 additions and 1029 deletions
+28
View File
@@ -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"
+14
View File
@@ -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/
+8 -7
View File
@@ -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"]
+3 -2
View File
@@ -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"])
api_router.include_router(setup.router, prefix="/setup", tags=["setup"])
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
+197 -1
View File
@@ -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
)
+65
View File
@@ -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()
}
+178 -24
View File
@@ -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}
)
"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
}
+327
View File
@@ -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
+86 -3
View File
@@ -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, [])
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 = {}
+228 -140
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 935 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

@@ -0,0 +1,44 @@
{% macro alert(variant='info', dismissible=False, id='') %}
<div
class="alert alert-{{ variant }} shadow-lg mb-4"
{% if id %}id="{{ id }}"{% endif %}
{% if dismissible %}x-data="{ open: true }" x-show="open"{% endif %}
>
{% if variant == 'info' %}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info flex-shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
{% elif variant == 'success' %}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-success flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{% elif variant == 'warning' %}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-warning flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
{% elif variant == 'error' %}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-error flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{% endif %}
<div>
{{ caller() }}
</div>
{% if dismissible %}
<button
type="button"
class="btn btn-ghost btn-sm btn-square"
x-on:click="open = false"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
{% endif %}
</div>
{% endmacro %}
{% macro alert_title() %}
<h5 class="font-medium text-base">
{{ caller() }}
</h5>
{% endmacro %}
{% macro alert_description() %}
<div class="text-sm">
{{ caller() }}
</div>
{% endmacro %}
@@ -0,0 +1,15 @@
{% macro button(variant='default', size='md', class='', type='button', disabled=False) %}
<button
type="{{ type }}"
class="btn btn-{{ variant }} btn-{{ size }} {{ class }}"
{% if disabled %}disabled{% endif %}
>
{{ caller() }}
</button>
{% endmacro %}
{% macro button_link(href='#', variant='default', size='md', class='') %}
<a href="{{ href }}" class="btn btn-{{ variant }} btn-{{ size }} {{ class }}">
{{ caller() }}
</a>
{% endmacro %}
@@ -0,0 +1,35 @@
{% macro card() %}
<div class="card bg-base-100 shadow">
{{ caller() }}
</div>
{% endmacro %}
{% macro card_header() %}
<div class="card-body pt-6 pb-2">
{{ caller() }}
</div>
{% endmacro %}
{% macro card_title(text='') %}
<h3 class="card-title text-lg font-bold">
{% if text %}{{ text }}{% else %}{{ caller() }}{% endif %}
</h3>
{% endmacro %}
{% macro card_description(text='') %}
<p class="text-sm opacity-70 mt-1">
{% if text %}{{ text }}{% else %}{{ caller() }}{% endif %}
</p>
{% endmacro %}
{% macro card_content() %}
<div class="card-body py-4">
{{ caller() }}
</div>
{% endmacro %}
{% macro card_footer() %}
<div class="card-actions justify-end p-4 pt-0">
{{ caller() }}
</div>
{% endmacro %}
@@ -0,0 +1,44 @@
{% macro input(
type='text',
name='',
id='',
value='',
placeholder='',
required=False,
disabled=False,
readonly=False,
class='',
min='',
max='',
step=''
) %}
<input
type="{{ type }}"
name="{{ name }}"
id="{{ id or name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
class="input input-bordered w-full {{ class }}"
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
{% if readonly %}readonly{% endif %}
{% if min %}min="{{ min }}"{% endif %}
{% if max %}max="{{ max }}"{% endif %}
{% if step %}step="{{ step }}"{% endif %}
>
{% endmacro %}
{% macro label(for='', required=False, class='') %}
<label
for="{{ for }}"
class="label"
>
<span class="label-text {{ class }}">{{ caller() }}{% if required %} <span class="text-error">*</span>{% endif %}</span>
</label>
{% endmacro %}
{% macro form_group() %}
<div class="form-control w-full mb-4">
{{ caller() }}
</div>
{% endmacro %}
@@ -0,0 +1,37 @@
{% macro table(class='', zebra=True) %}
<div class="overflow-x-auto">
<table class="table {% if zebra %}table-zebra{% endif %} {{ class }}">
{{ caller() }}
</table>
</div>
{% endmacro %}
{% macro thead() %}
<thead>
{{ caller() }}
</thead>
{% endmacro %}
{% macro tbody() %}
<tbody>
{{ caller() }}
</tbody>
{% endmacro %}
{% macro tr(class='') %}
<tr class="{{ class }}">
{{ caller() }}
</tr>
{% endmacro %}
{% macro th(class='') %}
<th class="{{ class }}">
{{ caller() }}
</th>
{% endmacro %}
{% macro td(class='', colspan='', rowspan='') %}
<td class="{{ class }}" {% if colspan %}colspan="{{ colspan }}"{% endif %} {% if rowspan %}rowspan="{{ rowspan }}"{% endif %}>
{{ caller() }}
</td>
{% endmacro %}
+408
View File
@@ -0,0 +1,408 @@
{% extends "layouts/base.html" %}
{% block title %}DMARQ - DaisyUI Components Demo{% endblock %}
{% block page_title %}DaisyUI Components Demo{% endblock %}
{% block content %}
<div class="space-y-12">
<!-- Introduction -->
<section>
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl">DaisyUI Components for DMARQ</h2>
<p class="text-lg mt-2">
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.
</p>
<div class="mt-4 flex gap-2">
<button class="btn btn-primary">Primary</button>
<button class="btn btn-secondary">Secondary</button>
<button class="btn btn-accent">Accent</button>
</div>
</div>
</div>
</section>
<!-- Theme Colors -->
<section>
<h2 class="text-2xl font-semibold mb-4">Theme Colors</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="p-4 bg-primary text-primary-content rounded-lg">primary</div>
<div class="p-4 bg-secondary text-secondary-content rounded-lg">secondary</div>
<div class="p-4 bg-accent text-accent-content rounded-lg">accent</div>
<div class="p-4 bg-neutral text-neutral-content rounded-lg">neutral</div>
<div class="p-4 bg-base-100 border border-base-300 rounded-lg">base-100</div>
<div class="p-4 bg-base-200 rounded-lg">base-200</div>
<div class="p-4 bg-base-300 rounded-lg">base-300</div>
<div class="p-4 bg-info text-info-content rounded-lg">info</div>
<div class="p-4 bg-success text-success-content rounded-lg">success</div>
<div class="p-4 bg-warning text-warning-content rounded-lg">warning</div>
<div class="p-4 bg-error text-error-content rounded-lg">error</div>
</div>
</section>
<!-- Buttons -->
<section>
<h2 class="text-2xl font-semibold mb-4">Buttons</h2>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="text-xl font-medium mb-4">Button Variants</h3>
<div class="flex flex-wrap gap-2 mb-8">
<button class="btn">Default</button>
<button class="btn btn-neutral">Neutral</button>
<button class="btn btn-primary">Primary</button>
<button class="btn btn-secondary">Secondary</button>
<button class="btn btn-accent">Accent</button>
<button class="btn btn-ghost">Ghost</button>
<button class="btn btn-link">Link</button>
</div>
<h3 class="text-xl font-medium mb-4">Button States</h3>
<div class="flex flex-wrap gap-2 mb-8">
<button class="btn btn-primary">Normal</button>
<button class="btn btn-primary btn-outline">Outline</button>
<button class="btn btn-primary" disabled>Disabled</button>
<button class="btn btn-primary loading">Loading</button>
</div>
<h3 class="text-xl font-medium mb-4">Button Sizes</h3>
<div class="flex flex-wrap items-center gap-2">
<button class="btn btn-primary btn-xs">Extra Small</button>
<button class="btn btn-primary btn-sm">Small</button>
<button class="btn btn-primary">Normal</button>
<button class="btn btn-primary btn-lg">Large</button>
</div>
</div>
</div>
</section>
<!-- Alerts -->
<section>
<h2 class="text-2xl font-semibold mb-4">Alerts</h2>
<div class="space-y-4">
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info flex-shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<div>
<h5 class="font-medium text-base">Information</h5>
<div class="text-sm">This is an informational alert.</div>
</div>
</div>
<div class="alert alert-success">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-success flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div>
<h5 class="font-medium text-base">Success!</h5>
<div class="text-sm">Your action was completed successfully.</div>
</div>
</div>
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-warning flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<div>
<h5 class="font-medium text-base">Warning!</h5>
<div class="text-sm">This action requires your attention.</div>
</div>
</div>
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-error flex-shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<div>
<h5 class="font-medium text-base">Error!</h5>
<div class="text-sm">There was an error processing your request.</div>
</div>
</div>
</div>
</section>
<!-- Forms -->
<section>
<h2 class="text-2xl font-semibold mb-4">Forms</h2>
<div class="card bg-base-100 shadow">
<div class="card-body">
<form>
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div class="form-control w-full">
<label class="label">
<span class="label-text">First name</span>
<span class="label-text-alt">Required</span>
</label>
<input type="text" placeholder="John" class="input input-bordered w-full" />
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Last name</span>
</label>
<input type="text" placeholder="Doe" class="input input-bordered w-full" />
</div>
</div>
<div class="grid gap-6 mb-6 md:grid-cols-2">
<div class="form-control w-full">
<label class="label">
<span class="label-text">Email</span>
</label>
<input type="email" placeholder="john.doe@example.com" class="input input-bordered w-full" />
</div>
<div class="form-control w-full">
<label class="label">
<span class="label-text">Phone</span>
</label>
<input type="tel" placeholder="123-456-7890" class="input input-bordered w-full" />
</div>
</div>
<div class="mb-6">
<div class="form-control">
<label class="label">
<span class="label-text">Message</span>
</label>
<textarea class="textarea textarea-bordered h-24" placeholder="Your message here"></textarea>
</div>
</div>
<div class="mb-6">
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text">Agree to terms and conditions</span>
<input type="checkbox" class="checkbox checkbox-primary" />
</label>
</div>
</div>
<div class="flex items-center justify-end space-x-4">
<button type="button" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
</div>
</section>
<!-- Tables -->
<section>
<h2 class="text-2xl font-semibold mb-4">Tables</h2>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<!-- head -->
<thead>
<tr>
<th></th>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- rows -->
<tr>
<th>1</th>
<td>John Doe</td>
<td>john@example.com</td>
<td>Admin</td>
<td><span class="badge badge-success">Active</span></td>
<td>
<div class="flex gap-2">
<button class="btn btn-xs">Edit</button>
<button class="btn btn-error btn-xs">Delete</button>
</div>
</td>
</tr>
<tr>
<th>2</th>
<td>Jane Smith</td>
<td>jane@example.com</td>
<td>User</td>
<td><span class="badge badge-warning">Pending</span></td>
<td>
<div class="flex gap-2">
<button class="btn btn-xs">Edit</button>
<button class="btn btn-error btn-xs">Delete</button>
</div>
</td>
</tr>
<tr>
<th>3</th>
<td>Robert Johnson</td>
<td>robert@example.com</td>
<td>User</td>
<td><span class="badge badge-error">Inactive</span></td>
<td>
<div class="flex gap-2">
<button class="btn btn-xs">Edit</button>
<button class="btn btn-error btn-xs">Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Badges and Tags -->
<section>
<h2 class="text-2xl font-semibold mb-4">Badges and Tags</h2>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="text-xl font-medium mb-4">Badge Variants</h3>
<div class="flex flex-wrap gap-2 mb-8">
<span class="badge">Neutral</span>
<span class="badge badge-primary">Primary</span>
<span class="badge badge-secondary">Secondary</span>
<span class="badge badge-accent">Accent</span>
<span class="badge badge-info">Info</span>
<span class="badge badge-success">Success</span>
<span class="badge badge-warning">Warning</span>
<span class="badge badge-error">Error</span>
</div>
<h3 class="text-xl font-medium mb-4">Badge Sizes</h3>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-xs">Extra Small</span>
<span class="badge badge-sm">Small</span>
<span class="badge">Normal</span>
<span class="badge badge-lg">Large</span>
</div>
</div>
</div>
</section>
<!-- Modals -->
<section>
<h2 class="text-2xl font-semibold mb-4">Modals</h2>
<div class="card bg-base-100 shadow">
<div class="card-body">
<!-- Open the modal using the button -->
<button class="btn btn-primary" onclick="demo_modal.showModal()">Open Modal</button>
<dialog id="demo_modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">Hello!</h3>
<p class="py-4">This is a modal example using DaisyUI.</p>
<div class="modal-action">
<form method="dialog">
<!-- if there is a button in form, it will close the modal -->
<button class="btn">Close</button>
</form>
</div>
</div>
</dialog>
</div>
</div>
</section>
<!-- Progress -->
<section>
<h2 class="text-2xl font-semibold mb-4">Progress</h2>
<div class="card bg-base-100 shadow">
<div class="card-body space-y-4">
<div>
<div class="text-sm mb-1">Default Progress (75%)</div>
<progress class="progress w-full" value="75" max="100"></progress>
</div>
<div>
<div class="text-sm mb-1">Primary Progress (45%)</div>
<progress class="progress progress-primary w-full" value="45" max="100"></progress>
</div>
<div>
<div class="text-sm mb-1">Secondary Progress (60%)</div>
<progress class="progress progress-secondary w-full" value="60" max="100"></progress>
</div>
<div>
<div class="text-sm mb-1">Success Progress (90%)</div>
<progress class="progress progress-success w-full" value="90" max="100"></progress>
</div>
<div>
<div class="text-sm mb-1">Warning Progress (50%)</div>
<progress class="progress progress-warning w-full" value="50" max="100"></progress>
</div>
<div>
<div class="text-sm mb-1">Error Progress (25%)</div>
<progress class="progress progress-error w-full" value="25" max="100"></progress>
</div>
</div>
</div>
</section>
<!-- Tabs -->
<section>
<h2 class="text-2xl font-semibold mb-4">Tabs</h2>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div role="tablist" class="tabs tabs-bordered">
<input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Overview" checked />
<div role="tabpanel" class="tab-content p-6">
<p>This is the Overview tab content. Tabs are useful for organizing content into different sections.</p>
</div>
<input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Details" />
<div role="tabpanel" class="tab-content p-6">
<p>This is the Details tab content. You can include any type of content here, including forms, tables, or other components.</p>
</div>
<input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Stats" />
<div role="tabpanel" class="tab-content p-6">
<p>This is the Stats tab content. Tabs help keep your interface clean and organized.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Accordion -->
<section>
<h2 class="text-2xl font-semibold mb-4">Accordion</h2>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input type="radio" name="accordion-1" checked="checked" />
<div class="collapse-title font-medium">
What is DMARQ?
</div>
<div class="collapse-content">
<p>DMARQ is a DMARC monitoring tool that helps organizations track and analyze their email authentication compliance.</p>
</div>
</div>
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input type="radio" name="accordion-1" />
<div class="collapse-title font-medium">
How does DMARC work?
</div>
<div class="collapse-content">
<p>DMARC (Domain-based Message Authentication, Reporting and Conformance) is an email authentication protocol that builds upon SPF and DKIM to help prevent email spoofing.</p>
</div>
</div>
<div class="collapse collapse-arrow bg-base-200">
<input type="radio" name="accordion-1" />
<div class="collapse-title font-medium">
What are the benefits of using DMARQ?
</div>
<div class="collapse-content">
<p>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.</p>
</div>
</div>
</div>
</div>
</section>
<!-- Tooltips -->
<section>
<h2 class="text-2xl font-semibold mb-4">Tooltips</h2>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex justify-center gap-6">
<div class="tooltip" data-tip="Top tooltip">
<button class="btn">Top</button>
</div>
<div class="tooltip tooltip-bottom" data-tip="Bottom tooltip">
<button class="btn">Bottom</button>
</div>
<div class="tooltip tooltip-left" data-tip="Left tooltip">
<button class="btn">Left</button>
</div>
<div class="tooltip tooltip-right" data-tip="Right tooltip">
<button class="btn">Right</button>
</div>
</div>
</div>
</div>
</section>
</div>
{% endblock %}
+490
View File
@@ -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 %}
<div class="container mx-auto py-4" x-data="domainDetailsApp({{ domain_id }})">
<nav class="mb-4 text-sm">
<ol class="flex items-center space-x-2">
<li><a href="/" class="hover:text-primary">Dashboard</a></li>
<li><span class="text-muted-foreground px-2">/</span></li>
<li><a href="/domains" class="hover:text-primary">Domains</a></li>
<li><span class="text-muted-foreground px-2">/</span></li>
<li><span class="font-medium">{{ domain.name }}</span></li>
</ol>
</nav>
<div class="grid grid-cols-1 gap-6">
<!-- Domain Overview -->
<div class="flex justify-between items-start">
<div>
<h1 class="text-2xl font-bold mb-1">{{ domain.name }}</h1>
<p class="text-muted-foreground">{{ domain.description or "Domain monitored by DMARQ" }}</p>
</div>
<div class="flex space-x-2">
{% call button(variant="outline", size="sm") %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path><path d="m15 5 4 4"></path></svg>
Edit Domain
{% endcall %}
{% call button(variant="outline", size="sm") %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M12 2v7.5"></path><path d="m4.24 10.37 6.58-3.79"></path><path d="m3.24 16.98 7.5-1.01"></path><path d="m13.24 15.97 7.5 1.01"></path><path d="m13.24 3.79 6.58 3.79"></path><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Z"></path><path d="M12 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"></path></svg>
Check DNS
{% endcall %}
</div>
</div>
<!-- Domain Stats -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- DMARC Compliance Card -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}DMARC Compliance{% endcall %}
<div class="p-2 bg-primary/10 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
</div>
</div>
{% endcall %}
{% call card_content() %}
<div class="stat-card">
<div id="compliance-rate" class="stat-value" x-text="stats.complianceRate + '%'">-</div>
<p class="stat-description">Emails passing DMARC</p>
</div>
{% endcall %}
{% endcall %}
<!-- Total Emails Card -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Total Emails{% endcall %}
<div class="p-2 bg-secondary/10 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-secondary"><rect width="20" height="16" x="2" y="4" rx="2"></rect><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path></svg>
</div>
</div>
{% endcall %}
{% call card_content() %}
<div class="stat-card">
<div id="total-emails" class="stat-value" x-text="stats.totalEmails">-</div>
<p class="stat-description">Total emails processed</p>
</div>
{% endcall %}
{% endcall %}
<!-- Failed Emails Card -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Failed Emails{% endcall %}
<div class="p-2 bg-red-500/10 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-red-500"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>
</div>
</div>
{% endcall %}
{% call card_content() %}
<div class="stat-card">
<div id="failed-emails" class="stat-value" x-text="stats.failedEmails">-</div>
<p class="stat-description">Emails failing DMARC</p>
</div>
{% endcall %}
{% endcall %}
<!-- Reports Card -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Reports{% endcall %}
<div class="p-2 bg-accent/10 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-accent"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
</div>
</div>
{% endcall %}
{% call card_content() %}
<div class="stat-card">
<div id="report-count" class="stat-value" x-text="stats.reportCount">-</div>
<p class="stat-description">DMARC reports received</p>
</div>
{% endcall %}
{% endcall %}
</div>
<!-- Compliance Chart -->
{% 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() %}
<div class="h-64">
<canvas id="compliance-chart"></canvas>
</div>
{% endcall %}
{% endcall %}
<!-- DNS Records -->
{% 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() %}
<div class="grid gap-4">
<div>
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">DMARC Record</span>
<span x-show="dns.dmarc" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="!dns.dmarc" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.dmarcRecord || 'No DMARC record found'">-</div>
</div>
<div>
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">SPF Record</span>
<span x-show="dns.spf" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="!dns.spf" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.spfRecord || 'No SPF record found'">-</div>
</div>
<div>
<h3 class="font-semibold mb-1 flex items-center">
<span class="mr-2">DKIM Selectors</span>
<span x-show="dns.dkim && dns.dkim.length > 0" class="inline-flex h-2 w-2 rounded-full bg-green-500"></span>
<span x-show="!dns.dkim || dns.dkim.length === 0" class="inline-flex h-2 w-2 rounded-full bg-red-500"></span>
</h3>
<div class="bg-muted p-2 rounded text-sm overflow-x-auto font-mono" x-text="dns.dkimSelectors || 'No DKIM selectors configured'">-</div>
</div>
</div>
{% endcall %}
{% endcall %}
<!-- Source Report Table -->
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Sending Sources{% endcall %}
<div class="space-x-2">
<select x-model="filters.dateRange" class="select select-sm select-bordered">
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="all">All time</option>
</select>
<input
x-model="filters.sourceFilter"
type="text"
placeholder="Filter sources..."
class="input input-sm input-bordered max-w-xs"
>
</div>
</div>
{% 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() %}
<template x-if="sources.length === 0">
<tr>
<td colspan="6" class="text-center py-4">
<div class="text-muted-foreground">No data available for this time period</div>
</td>
</tr>
</template>
<template x-for="source in filteredSources" :key="source.ip">
{% call tr() %}
{% call td() %}
<span x-text="source.ip"></span>
{% endcall %}
{% call td() %}
<span x-text="source.count"></span>
{% endcall %}
{% call td() %}
<template x-if="source.spf === 'pass'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
</template>
<template x-if="source.spf === 'fail'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
</template>
<template x-if="source.spf === 'neutral' || source.spf === 'none'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800" x-text="source.spf"></span>
</template>
{% endcall %}
{% call td() %}
<template x-if="source.dkim === 'pass'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
</template>
<template x-if="source.dkim === 'fail'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
</template>
<template x-if="source.dkim === 'neutral' || source.dkim === 'none'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-gray-100 text-gray-800" x-text="source.dkim"></span>
</template>
{% endcall %}
{% call td() %}
<template x-if="source.dmarc === 'pass'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">Pass</span>
</template>
<template x-if="source.dmarc === 'fail'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Fail</span>
</template>
{% endcall %}
{% call td() %}
<template x-if="source.disposition === 'none'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">None</span>
</template>
<template x-if="source.disposition === 'quarantine'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-yellow-100 text-yellow-800">Quarantine</span>
</template>
<template x-if="source.disposition === 'reject'">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs bg-red-100 text-red-800">Reject</span>
</template>
{% endcall %}
{% endcall %}
</template>
{% endcall %}
{% endcall %}
{% endcall %}
{% endcall %}
<!-- Recent Reports -->
{% 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() %}
<template x-if="reports.length === 0">
<tr>
<td colspan="6" class="text-center py-4">
<div class="text-muted-foreground">No reports available</div>
</td>
</tr>
</template>
<template x-for="report in reports" :key="report.id">
{% call tr() %}
{% call td() %}
<span x-text="formatDate(report.begin_date)"></span>
{% endcall %}
{% call td() %}
<span x-text="report.org_name"></span>
{% endcall %}
{% call td() %}
<span x-text="report.total_emails"></span>
{% endcall %}
{% call td() %}
<span class="inline-flex items-center px-2 py-1 rounded text-xs"
:class="getPassRateClass(report.pass_rate)">
<span x-text="report.pass_rate + '%'"></span>
</span>
{% endcall %}
{% call td() %}
<span x-text="report.policy"></span>
{% endcall %}
{% call td() %}
<a :href="'/reports/' + report.id" class="btn btn-sm btn-outline">
View
</a>
{% endcall %}
{% endcall %}
</template>
{% endcall %}
{% endcall %}
{% endcall %}
{% endcall %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.3.0/dist/chart.umd.min.js"></script>
<script>
function domainDetailsApp(domainId) {
return {
domainId: domainId,
stats: {
complianceRate: '-',
totalEmails: '-',
failedEmails: '-',
reportCount: '-'
},
dns: {
dmarc: false,
dmarcRecord: '',
spf: false,
spfRecord: '',
dkim: false,
dkimSelectors: ''
},
reports: [],
sources: [],
complianceChart: null,
filters: {
dateRange: '30',
sourceFilter: ''
},
init() {
this.fetchDomainStats();
this.fetchDNSRecords();
this.fetchReports();
this.fetchSources();
this.$watch('filters.dateRange', () => {
this.fetchSources();
});
},
get filteredSources() {
if (!this.sources) return [];
return this.sources.filter(source => {
if (!this.filters.sourceFilter) return true;
return source.ip.toLowerCase().includes(this.filters.sourceFilter.toLowerCase());
});
},
async fetchDomainStats() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/stats`);
if (response.ok) {
const data = await response.json();
this.stats = data;
}
} catch (error) {
console.error('Error fetching domain stats:', error);
}
},
async fetchDNSRecords() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/dns`);
if (response.ok) {
const data = await response.json();
this.dns = data;
}
} catch (error) {
console.error('Error fetching DNS records:', error);
}
},
async fetchReports() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/reports?limit=10`);
if (response.ok) {
const data = await response.json();
this.reports = data.reports;
this.initComplianceChart(data.compliance_timeline);
}
} catch (error) {
console.error('Error fetching reports:', error);
}
},
async fetchSources() {
try {
const response = await fetch(`/api/v1/domains/${this.domainId}/sources?days=${this.filters.dateRange}`);
if (response.ok) {
const data = await response.json();
this.sources = data.sources;
}
} catch (error) {
console.error('Error fetching sources:', error);
}
},
initComplianceChart(timelineData) {
if (!timelineData) return;
const ctx = document.getElementById('compliance-chart').getContext('2d');
if (this.complianceChart) {
this.complianceChart.destroy();
}
const labels = timelineData.map(item => item.date);
const complianceData = timelineData.map(item => item.compliance_rate);
this.complianceChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Compliance Rate',
data: complianceData,
borderColor: '#1A237E',
backgroundColor: 'rgba(26, 35, 126, 0.1)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100,
ticks: {
callback: value => value + '%'
}
}
},
plugins: {
tooltip: {
callbacks: {
label: function(context) {
return context.parsed.y + '%';
}
}
}
}
}
});
},
formatDate(timestamp) {
const date = new Date(timestamp * 1000);
return date.toLocaleDateString();
},
getPassRateClass(rate) {
if (rate >= 90) return 'bg-green-100 text-green-800';
if (rate >= 50) return 'bg-yellow-100 text-yellow-800';
return 'bg-red-100 text-red-800';
}
};
}
</script>
{% endblock %}
+135
View File
@@ -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 %}
<div class="container mx-auto py-4" x-data="domainsApp()">
<h1 class="text-2xl font-bold mb-6">Domain Management</h1>
{% if error %}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
<p>{{ error }}</p>
</div>
{% endif %}
<!-- Domain List -->
<div class="mb-8">
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}Monitored Domains{% endcall %}
{% call button(variant="outline", size="sm") %}
<span class="mr-1">+</span> Add Domain
{% endcall %}
</div>
{% 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() %}
<template x-if="domains.length === 0">
{% call tr() %}
{% call td(colspan="5", class="text-center") %}
<p class="py-4 text-muted-foreground">No domains found. Add a domain to get started.</p>
{% endcall %}
{% endcall %}
</template>
<template x-for="(domain, index) in domains" :key="index">
{% call tr() %}
{% call td() %}
<div class="font-medium" x-text="domain.name"></div>
{% endcall %}
{% call td() %}
<div class="flex items-center">
<span class="w-2 h-2 rounded-full"
:class="domain.dmarc_status ? 'bg-green-500' : 'bg-red-500'"></span>
<span class="ml-2" x-text="domain.dmarc_policy || 'Not configured'"></span>
</div>
{% endcall %}
{% call td() %}
<div class="flex items-center">
<span class="w-2 h-2 rounded-full"
:class="domain.spf_status ? 'bg-green-500' : 'bg-red-500'"></span>
<span class="ml-2" x-text="domain.spf_status ? 'Configured' : 'Missing'"></span>
</div>
{% endcall %}
{% call td() %}
<div class="flex items-center">
<span class="w-2 h-2 rounded-full"
:class="domain.dkim_status ? 'bg-green-500' : 'bg-red-500'"></span>
<span class="ml-2" x-text="domain.dkim_status ? 'Configured' : 'Missing'"></span>
</div>
{% endcall %}
{% call td("text-right") %}
<div class="flex justify-end space-x-2">
<a :href="'/domain/' + domain.name" class="btn btn-sm btn-outline">
Details
</a>
{% call button(variant="outline", size="sm") %}
Edit
{% endcall %}
</div>
{% endcall %}
{% endcall %}
</template>
{% endcall %}
{% endcall %}
{% endcall %}
{% endcall %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function domainsApp() {
return {
domains: [],
init() {
// Fetch domains from server
this.fetchDomains();
},
async fetchDomains() {
try {
const response = await fetch('/api/v1/domains/summary');
if (response.ok) {
const data = await response.json();
// Format domains for display
this.domains = data.domains.map(domain => ({
name: domain.domain_name,
dmarc_status: true, // In Milestone 1, assume DMARC is configured if we have reports
dmarc_policy: domain.policy || 'p=none',
spf_status: true, // In future milestones, this will come from DNS checks
dkim_status: true, // In future milestones, this will come from DNS checks
reports_count: domain.report_count,
emails_count: domain.total_emails,
compliance_rate: domain.pass_rate
}));
} else {
console.error('Error fetching domains:', response.status);
}
} catch (error) {
console.error('Error fetching domains:', error);
}
}
}
}
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en" data-theme="dmarqlight" class="dark:data-theme-dmarqdark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DMARQ - DMARC Monitoring{% endblock %}</title>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Open+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Tailwind CSS & DaisyUI via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.24/dist/full.css" rel="stylesheet" type="text/css"/>
<!-- Alpine.js for interactivity -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% block head %}{% endblock %}
</head>
<body class="min-h-screen bg-base-100 font-body antialiased">
<!-- Updated Menu Bar -->
<header class="navbar bg-primary text-primary-content">
<div class="flex-1">
<a href="/" class="btn btn-ghost normal-case text-xl">
<img src="/static/img/monogram_light.png" alt="DMARQ Logo" class="w-8 h-8 mr-2">
DMARQ
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1">
<li><a href="/">Dashboard</a></li>
<li><a href="/domains">Domains</a></li>
<li><a href="/reports">Reports</a></li>
<li><a href="/upload">Upload</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
</div>
</header>
<!-- Updated Main Content Area with page-specific data initialization -->
<main class="p-4 md:p-6">
{% block content %}{% endblock %}
</main>
<!-- Scripts -->
{% block scripts %}{% endblock %}
<!-- Initialize theme from localStorage -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const darkMode = localStorage.getItem('darkMode') === 'true';
if (darkMode) {
document.documentElement.classList.add('dark');
document.documentElement.setAttribute('data-theme', 'dmarqdark');
} else {
document.documentElement.classList.remove('dark');
document.documentElement.setAttribute('data-theme', 'dmarqlight');
}
});
</script>
</body>
</html>
+219
View File
@@ -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 %}
<div class="container mx-auto py-4" x-data="reportsApp()">
<h1 class="text-2xl font-bold mb-6">DMARC Reports</h1>
<!-- Report Filters -->
<div class="mb-6">
{% call card() %}
{% call card_content() %}
<div class="flex flex-wrap gap-4">
<div class="w-full sm:w-auto">
<label class="block text-sm font-medium mb-1">Domain</label>
<select class="select select-bordered w-full" x-model="filters.domain">
<option value="">All Domains</option>
<template x-for="domain in domains" :key="domain">
<option x-text="domain" :value="domain"></option>
</template>
</select>
</div>
<div class="w-full sm:w-auto">
<label class="block text-sm font-medium mb-1">Report Type</label>
<select class="select select-bordered w-full" x-model="filters.reportType">
<option value="">All Types</option>
<option value="aggregate">Aggregate (RUA)</option>
<option value="forensic">Forensic (RUF)</option>
</select>
</div>
<div class="w-full sm:w-auto">
<label class="block text-sm font-medium mb-1">Date Range</label>
<select class="select select-bordered w-full" x-model="filters.dateRange">
<option value="7">Last 7 days</option>
<option value="14">Last 14 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="all">All time</option>
</select>
</div>
<div class="w-full sm:w-auto flex items-end">
{% call button(variant="outline") %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
Reset Filters
{% endcall %}
</div>
</div>
{% endcall %}
{% endcall %}
</div>
<!-- Reports Table -->
<div class="mb-8">
{% call card() %}
{% call card_header() %}
<div class="flex items-center justify-between">
{% call card_title() %}DMARC Reports{% endcall %}
</div>
{% call card_description() %}
Showing <span x-text="filteredReports.length"></span> 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() %}
<template x-for="(report, index) in filteredReports" :key="index">
{% call tr() %}
{% call td() %}
<div x-text="formatDate(report.date)"></div>
{% endcall %}
{% call td() %}
<div class="inline-flex items-center px-2 py-1 rounded text-xs"
:class="report.type === 'aggregate' ? 'bg-blue-100 text-blue-800' : 'bg-amber-100 text-amber-800'">
<span x-text="report.type === 'aggregate' ? 'Aggregate' : 'Forensic'"></span>
</div>
{% endcall %}
{% call td() %}
<div class="font-medium" x-text="report.domain"></div>
{% endcall %}
{% call td() %}
<div x-text="report.organization"></div>
{% endcall %}
{% call td() %}
<div x-text="report.messages"></div>
{% endcall %}
{% call td() %}
<div class="inline-flex items-center px-2 py-1 rounded"
:class="getPassRateColor(report.passRate)">
<span x-text="report.passRate + '%'"></span>
</div>
{% endcall %}
{% call td("text-right") %}
{% call button(variant="outline", size="sm") %}
View Details
{% endcall %}
{% endcall %}
{% endcall %}
</template>
{% endcall %}
{% endcall %}
{% endcall %}
{% endcall %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function reportsApp() {
return {
filters: {
domain: '',
reportType: '',
dateRange: '30'
},
domains: ['example.com', 'mydomain.com'],
reports: [
{
id: 1,
date: '2023-04-15',
type: 'aggregate',
domain: 'example.com',
organization: 'Google',
messages: 128,
passRate: 96
},
{
id: 2,
date: '2023-04-15',
type: 'aggregate',
domain: 'mydomain.com',
organization: 'Microsoft',
messages: 64,
passRate: 100
},
{
id: 3,
date: '2023-04-14',
type: 'forensic',
domain: 'example.com',
organization: 'Yahoo',
messages: 1,
passRate: 0
}
],
init() {
// When API is ready, fetch reports from server
// this.fetchReports();
},
get filteredReports() {
return this.reports.filter(report => {
// Filter by domain
if (this.filters.domain && report.domain !== this.filters.domain) {
return false;
}
// Filter by report type
if (this.filters.reportType && report.type !== this.filters.reportType) {
return false;
}
// Filter by date range
if (this.filters.dateRange !== 'all') {
const days = parseInt(this.filters.dateRange);
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const reportDate = new Date(report.date);
if (reportDate < cutoff) {
return false;
}
}
return true;
});
},
formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString();
},
getPassRateColor(rate) {
if (rate >= 90) return 'bg-green-100 text-green-800';
if (rate >= 50) return 'bg-yellow-100 text-yellow-800';
return 'bg-red-100 text-red-800';
},
async fetchReports() {
try {
const response = await fetch('/api/v1/reports');
this.reports = await response.json();
// Extract unique domains
this.domains = [...new Set(this.reports.map(r => r.domain))];
} catch (error) {
console.error('Error fetching reports:', error);
}
}
}
}
</script>
{% endblock %}
+336
View File
@@ -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 %}
<div class="grid gap-4 md:gap-8 py-4">
<!-- IMAP Configuration -->
{% 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() %}
<form id="imap-form" class="space-y-6" x-data="imapForm()">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
{% 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 %}
<div class="flex items-center space-x-2">
<input type="checkbox" id="imap_ssl" name="imap_ssl" class="h-4 w-4 rounded border-border text-primary focus:ring-primary" checked />
<label for="imap_ssl" class="text-sm text-muted-foreground">Enable SSL/TLS connection (recommended)</label>
</div>
{% endcall %}
</div>
<div class="space-y-4">
{% 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 %}
<div class="relative">
{{ input(type="password", name="imap_password", id="imap_password", required=True) }}
<button
type="button"
class="absolute right-2 top-2.5 text-muted-foreground hover:text-foreground"
x-on:click="togglePassword"
>
<svg x-show="!showPassword" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"></path><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"></path><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"></path><line x1="2" x2="22" y1="2" y2="22"></line></svg>
<svg x-show="showPassword" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" x-cloak><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle></svg>
</button>
</div>
{% 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") }}
<p class="text-xs text-muted-foreground mt-1">How often to check for new reports (minimum 15 minutes)</p>
{% endcall %}
</div>
</div>
<div id="test-connection-result" x-show="testResult" x-cloak>
<template x-if="testStatus === 'success'">
{% call alert(variant="success") %}
{% call alert_title() %}Connection Successful{% endcall %}
{% call alert_description() %}
<p x-text="testResult"></p>
{% endcall %}
{% endcall %}
</template>
<template x-if="testStatus === 'error'">
{% call alert(variant="error") %}
{% call alert_title() %}Connection Failed{% endcall %}
{% call alert_description() %}
<p x-text="testResult"></p>
{% endcall %}
{% endcall %}
</template>
</div>
<div id="save-result" x-show="saveResult" x-cloak>
<template x-if="saveStatus === 'success'">
{% call alert(variant="success") %}
{% call alert_title() %}Settings Saved{% endcall %}
{% call alert_description() %}
<p x-text="saveResult"></p>
{% endcall %}
{% endcall %}
</template>
<template x-if="saveStatus === 'error'">
{% call alert(variant="error") %}
{% call alert_title() %}Save Failed{% endcall %}
{% call alert_description() %}
<p x-text="saveResult"></p>
{% endcall %}
{% endcall %}
</template>
</div>
<div class="flex items-center justify-end space-x-4">
<button
type="button"
class="btn btn-outline btn-md"
x-on:click="testConnection"
x-bind:disabled="isTesting || isSaving"
>
<span x-show="!isTesting">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>
Test Connection
</span>
<span x-show="isTesting" class="flex items-center" x-cloak>
<svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Testing...
</span>
</button>
<button
type="submit"
class="btn btn-default btn-md"
x-bind:disabled="isTesting || isSaving"
>
<span x-show="!isSaving">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
Save Configuration
</span>
<span x-show="isSaving" class="flex items-center" x-cloak>
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Saving...
</span>
</button>
</div>
</form>
{% endcall %}
{% endcall %}
<!-- DMARC Policy Management -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}DMARC Policy Management{% endcall %}
{% call card_description() %}
Configure default DMARC policy settings for newly added domains
{% endcall %}
{% endcall %}
{% call card_content() %}
<form id="dmarc-policy-form" class="space-y-6" x-data="{isUpdating: false, updateResult: ''}">
<div class="space-y-4">
{% call form_group() %}
{% call label(for="default_policy") %}Default DMARC Policy{% endcall %}
<select id="default_policy" name="default_policy" class="input w-full">
<option value="none">None (monitoring only)</option>
<option value="quarantine">Quarantine (send to spam)</option>
<option value="reject">Reject (block delivery)</option>
</select>
<p class="text-xs text-muted-foreground mt-1">Policy applied to new domains when no specific policy is set</p>
{% endcall %}
{% call form_group() %}
{% call label(for="percent") %}Percentage{% endcall %}
<div class="flex items-center">
<input type="range" id="percent" name="percent" min="0" max="100" value="100" class="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer" />
<span class="ml-2 text-sm font-medium w-10" id="percent-display">100%</span>
</div>
<p class="text-xs text-muted-foreground mt-1">Percentage of messages to which the DMARC policy is applied</p>
{% endcall %}
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-default btn-md">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
Save Policy Settings
</button>
</div>
</form>
{% endcall %}
{% endcall %}
</div>
{% endblock %}
{% block scripts %}
<script>
function imapForm() {
return {
showPassword: false,
isTesting: false,
isSaving: false,
testResult: '',
testStatus: '',
saveResult: '',
saveStatus: '',
togglePassword() {
this.showPassword = !this.showPassword;
const passwordInput = document.getElementById('imap_password');
passwordInput.type = this.showPassword ? 'text' : 'password';
},
async testConnection() {
this.isTesting = true;
this.testResult = '';
const formData = new FormData(document.getElementById('imap-form'));
const data = Object.fromEntries(formData.entries());
data.imap_ssl = formData.get('imap_ssl') === 'on';
try {
const response = await fetch('/api/v1/admin/test-imap', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
this.testStatus = 'success';
this.testResult = 'Successfully connected to the IMAP server. Found ' + (result.message_count || 0) + ' messages in the inbox.';
} else {
throw new Error(result.detail || 'Failed to connect to the IMAP server');
}
} catch (error) {
this.testStatus = 'error';
this.testResult = `Connection failed: ${error.message}`;
} finally {
this.isTesting = false;
}
},
init() {
// Load existing IMAP settings
this.loadImapSettings();
// Update percent display
const percentInput = document.getElementById('percent');
const percentDisplay = document.getElementById('percent-display');
if (percentInput && percentDisplay) {
percentInput.addEventListener('input', function() {
percentDisplay.textContent = this.value + '%';
});
}
// Handle form submission
const form = document.getElementById('imap-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
await this.saveImapSettings();
});
},
async loadImapSettings() {
try {
const response = await fetch('/api/v1/admin/imap-settings');
if (response.ok) {
const settings = await response.json();
document.getElementById('imap_server').value = settings.imap_server || '';
document.getElementById('imap_port').value = settings.imap_port || 993;
document.getElementById('imap_username').value = settings.imap_username || '';
document.getElementById('imap_password').value = settings.imap_password || '';
document.getElementById('imap_ssl').checked = settings.imap_ssl !== false;
document.getElementById('polling_interval').value = settings.polling_interval || 60;
}
} catch (error) {
console.error('Failed to load IMAP settings:', error);
}
},
async saveImapSettings() {
this.isSaving = true;
this.saveResult = '';
const formData = new FormData(document.getElementById('imap-form'));
const data = Object.fromEntries(formData.entries());
data.imap_ssl = formData.get('imap_ssl') === 'on';
try {
const response = await fetch('/api/v1/admin/imap-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
this.saveStatus = 'success';
this.saveResult = 'IMAP settings saved successfully. The system will now check for new DMARC reports based on your settings.';
} else {
throw new Error(result.detail || 'Failed to save IMAP settings');
}
} catch (error) {
this.saveStatus = 'error';
this.saveResult = `Save failed: ${error.message}`;
} finally {
this.isSaving = false;
}
}
};
}
// Initialize any scripts after DOM load
document.addEventListener('DOMContentLoaded', function() {
// DMARC policy form handling
const policyForm = document.getElementById('dmarc-policy-form');
if (policyForm) {
policyForm.addEventListener('submit', function(e) {
e.preventDefault();
// In a real app, you would save the policy settings here
alert('DMARC policy settings saved');
});
}
});
</script>
{% endblock %}
+203
View File
@@ -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 %}
<div class="grid gap-4 md:gap-8 py-4">
<!-- Upload Card -->
{% 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() %}
<form id="upload-form" enctype="multipart/form-data" class="space-y-6" x-data="uploadForm()">
<div
class="flex flex-col items-center justify-center border-2 border-dashed border-border rounded-lg p-8 text-center hover:bg-muted/50 transition-colors cursor-pointer relative"
x-on:dragover.prevent="dragover = true"
x-on:dragleave.prevent="dragover = false"
x-on:drop.prevent="handleDrop($event)"
x-bind:class="{'border-primary/50 bg-primary/5': dragover}"
>
<div class="mb-4 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mx-auto"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" x2="12" y1="3" y2="15"></line></svg>
</div>
<p class="mb-2 text-base font-medium">
<span class="text-primary">Click to upload</span> or drag and drop
</p>
<p class="text-sm text-muted-foreground">
XML, ZIP, or GZIP files only
</p>
<input id="report-file" type="file" name="file" accept=".xml,.zip,.gz,.gzip" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" x-on:change="handleFileSelect" />
</div>
<div id="file-selected" x-show="selectedFile" class="p-3 bg-muted rounded-md" x-cloak>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
<span id="file-name" class="text-sm font-medium" x-text="selectedFile"></span>
</div>
<button type="button" id="remove-file" class="text-muted-foreground hover:text-foreground" x-on:click="clearFile">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
</div>
</div>
<div id="upload-result" x-show="uploadResult" x-cloak>
<template x-if="uploadStatus === 'success'">
{% call alert(variant="success") %}
{% call alert_title() %}Upload Successful{% endcall %}
{% call alert_description() %}
<p x-text="uploadResult"></p>
{% endcall %}
{% endcall %}
</template>
<template x-if="uploadStatus === 'error'">
{% call alert(variant="error") %}
{% call alert_title() %}Upload Failed{% endcall %}
{% call alert_description() %}
<p x-text="uploadResult"></p>
{% endcall %}
{% endcall %}
</template>
<template x-if="uploadStatus === 'processing'">
{% call alert(variant="info") %}
{% call alert_title() %}Processing{% endcall %}
{% call alert_description() %}
<div class="flex items-center space-x-2">
<svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Uploading and processing your DMARC report...</span>
</div>
{% endcall %}
{% endcall %}
</template>
</div>
<div class="flex justify-end">
<button
type="submit"
class="btn btn-default btn-md"
x-bind:disabled="!selectedFile || isUploading"
>
<span x-show="!isUploading">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" x2="12" y1="3" y2="15"></line></svg>
Upload Report
</span>
<span x-show="isUploading" class="flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Uploading...
</span>
</button>
</div>
</form>
{% endcall %}
{% endcall %}
</div>
{% endblock %}
{% block scripts %}
<script>
function uploadForm() {
return {
selectedFile: '',
isUploading: false,
dragover: false,
uploadResult: '',
uploadStatus: '',
handleFileSelect(event) {
const file = event.target.files[0];
if (file) {
this.selectedFile = file.name;
}
},
handleDrop(event) {
this.dragover = false;
const file = event.dataTransfer.files[0];
if (file && (file.name.endsWith('.xml') || file.name.endsWith('.zip') || file.name.endsWith('.gz') || file.name.endsWith('.gzip'))) {
document.getElementById('report-file').files = event.dataTransfer.files;
this.selectedFile = file.name;
} else {
this.uploadStatus = 'error';
this.uploadResult = 'Invalid file type. Please upload XML, ZIP, or GZIP files only.';
}
},
clearFile() {
this.selectedFile = '';
this.uploadStatus = '';
this.uploadResult = '';
document.getElementById('report-file').value = '';
},
init() {
const form = document.getElementById('upload-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fileInput = document.getElementById('report-file');
if (!fileInput.files.length) {
this.uploadStatus = 'error';
this.uploadResult = 'Please select a file to upload.';
return;
}
this.isUploading = true;
this.uploadStatus = 'processing';
this.uploadResult = 'Uploading and processing report...';
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('/api/v1/reports/upload', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
this.uploadStatus = 'success';
this.uploadResult = `Report processed successfully. Found ${data.processed_records || 0} records for domain ${data.domain || ''}.`;
this.selectedFile = '';
fileInput.value = '';
// Refresh dashboard data after 1 second
setTimeout(() => {
window.dispatchEvent(new CustomEvent('dmarq:refresh-data'));
}, 1000);
} else {
throw new Error(data.detail || 'Unknown error');
}
} catch (error) {
this.uploadStatus = 'error';
this.uploadResult = `Upload failed: ${error.message}`;
} finally {
this.isUploading = false;
}
});
}
};
}
</script>
{% endblock %}
+2
View File
@@ -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: