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.
@@ -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"
|
||||||
@@ -172,3 +172,17 @@ cython_debug/
|
|||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.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/
|
||||||
@@ -2,23 +2,24 @@ FROM python:3.10-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install required system dependencies
|
# Install required system dependencies
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
gcc \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy requirements file
|
# Remove Tailwind & DaisyUI build; using CDN links instead
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# Install Python dependencies
|
# Copy Python requirements and install
|
||||||
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Copy application code
|
# Copy application code including templates and static assets
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose the port the app runs on
|
# Expose application port
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Command to run the application
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
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()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -8,4 +8,5 @@ api_router = APIRouter()
|
|||||||
api_router.include_router(health.router, tags=["health"])
|
api_router.include_router(health.router, tags=["health"])
|
||||||
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
|
api_router.include_router(domains.router, prefix="/domains", tags=["domains"])
|
||||||
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])
|
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"])
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import List, Optional, Dict, Any
|
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 pydantic import BaseModel
|
||||||
|
|
||||||
from app.services.report_store import ReportStore
|
from app.services.report_store import ReportStore
|
||||||
@@ -18,6 +19,55 @@ class DomainResponse(DomainBase):
|
|||||||
emails_count: int = 0
|
emails_count: int = 0
|
||||||
compliance_rate: float = 0.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):
|
class DomainSummaryResponse(BaseModel):
|
||||||
"""Domain summary for dashboard"""
|
"""Domain summary for dashboard"""
|
||||||
total_domains: int
|
total_domains: int
|
||||||
@@ -119,4 +169,150 @@ async def read_domain(domain_name: str):
|
|||||||
reports_count=summary.get("reports_processed", 0),
|
reports_count=summary.get("reports_processed", 0),
|
||||||
emails_count=summary.get("total_count", 0),
|
emails_count=summary.get("total_count", 0),
|
||||||
compliance_rate=summary.get("compliance_rate", 0.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
|
||||||
)
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,15 +1,67 @@
|
|||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request, BackgroundTasks
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from app.api.api_v1.api import api_router
|
from app.api.api_v1.api import api_router
|
||||||
from app.core.config import get_settings
|
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()
|
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:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application"""
|
"""Create and configure the FastAPI application"""
|
||||||
@@ -35,6 +87,31 @@ def create_app() -> FastAPI:
|
|||||||
# Mount static files directory
|
# Mount static files directory
|
||||||
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
|
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
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -46,33 +123,110 @@ templates = Jinja2Templates(directory=templates_dir)
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def root(request: Request):
|
async def dashboard(request: Request):
|
||||||
"""Root endpoint that serves the main HTML page"""
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
return templates.TemplateResponse(
|
|
||||||
"index.html",
|
|
||||||
{"request": request, "app_name": settings.PROJECT_NAME}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Frontend routes that should return the SPA template
|
# Individual page routes
|
||||||
@app.get("/dashboard", response_class=HTMLResponse)
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
@app.get("/login", response_class=HTMLResponse)
|
async def dashboard(request: Request):
|
||||||
@app.get("/setup", response_class=HTMLResponse)
|
|
||||||
@app.get("/domains", response_class=HTMLResponse)
|
|
||||||
@app.get("/reports", response_class=HTMLResponse)
|
|
||||||
@app.get("/settings", response_class=HTMLResponse)
|
|
||||||
async def serve_spa(request: Request):
|
|
||||||
"""Serve the SPA for frontend routes"""
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"index.html",
|
"dashboard.html", {"request": request, "app_name": settings.PROJECT_NAME}
|
||||||
{"request": request, "app_name": settings.PROJECT_NAME}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fallback for other routes (404 handling)
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
@app.get("/{path:path}", response_class=HTMLResponse)
|
async def login(request: Request):
|
||||||
async def catch_all(request: Request, path: str):
|
|
||||||
"""Catch-all route that serves the main HTML page for client-side routing"""
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"index.html",
|
"login.html", {"request": request, "app_name": settings.PROJECT_NAME}
|
||||||
{"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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any, Optional
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
class ReportStore:
|
class ReportStore:
|
||||||
"""
|
"""
|
||||||
@@ -29,6 +30,8 @@ class ReportStore:
|
|||||||
self.domain_reports: Dict[str, List[Dict[str, Any]]] = {}
|
self.domain_reports: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
# Domain -> summary stats
|
# Domain -> summary stats
|
||||||
self.domain_summary: Dict[str, Dict[str, Any]] = {}
|
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:
|
def add_report(self, report: Dict[str, Any]) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -48,6 +51,7 @@ class ReportStore:
|
|||||||
"failed_count": 0,
|
"failed_count": 0,
|
||||||
"reports_processed": 0,
|
"reports_processed": 0,
|
||||||
}
|
}
|
||||||
|
self.domain_sources[domain] = {}
|
||||||
|
|
||||||
# Add the new report
|
# Add the new report
|
||||||
self.domain_reports[domain].append(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]["failed_count"] += summary.get("failed_count", 0)
|
||||||
self.domain_summary[domain]["reports_processed"] += 1
|
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)
|
# Calculate compliance rate (percentage of passing emails)
|
||||||
if self.domain_summary[domain]["total_count"] > 0:
|
if self.domain_summary[domain]["total_count"] > 0:
|
||||||
pass_rate = (
|
pass_rate = (
|
||||||
@@ -96,14 +122,71 @@ class ReportStore:
|
|||||||
"""
|
"""
|
||||||
return self.domain_summary
|
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
|
Get all reports for a domain
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
domain: Domain name
|
domain: Domain name
|
||||||
|
limit: Optional limit on number of reports to return
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of reports or empty list if domain not found
|
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 = {}
|
||||||
@@ -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 {
|
.setup-progress {
|
||||||
display: flex;
|
@apply flex items-center mb-8;
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-step {
|
.setup-step {
|
||||||
position: relative;
|
@apply flex items-center;
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-weight: 500;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-step.active {
|
.setup-step-circle {
|
||||||
background-color: #3b82f6;
|
@apply w-8 h-8 rounded-full bg-base-300 flex items-center justify-center opacity-70;
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-step.completed {
|
.setup-step-active .setup-step-circle {
|
||||||
background-color: #10b981;
|
@apply bg-primary text-primary-content;
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.setup-progress:after {
|
.setup-step-completed .setup-step-circle {
|
||||||
content: '';
|
@apply bg-success text-success-content;
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background-color: #e5e7eb;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form Styles */
|
.setup-step-line {
|
||||||
input, select, textarea {
|
@apply w-16 h-1 bg-base-300;
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 1px solid #d1d5db;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.setup-step-completed .setup-step-line {
|
||||||
background-color: #3b82f6;
|
@apply bg-success;
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
/* Responsive layout */
|
||||||
background-color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
background-color: #9ca3af;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stats and Dashboard Styles */
|
|
||||||
.stat {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-stats .card {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Additional Utility Classes */
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-col {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items-center {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-center {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-between {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-4 {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-4 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation Styles */
|
|
||||||
.sidebar ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar ul li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar ul li a {
|
|
||||||
display: block;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar ul li a:hover {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar ul li a.active {
|
|
||||||
background-color: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 100%;
|
display: none;
|
||||||
height: auto;
|
}
|
||||||
position: static;
|
|
||||||
border-right: none;
|
.main-content {
|
||||||
border-bottom: 1px solid #e2e8f0;
|
margin-left: 0;
|
||||||
margin-bottom: 1rem;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
margin-left: 0;
|
margin-left: 16rem; /* ml-64 */
|
||||||
}
|
padding: 1.5rem; /* p-6 */
|
||||||
|
}
|
||||||
.dashboard-stats {
|
|
||||||
grid-template-columns: 1fr;
|
/* 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;
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 935 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 848 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 930 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 904 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 921 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 885 KiB |
|
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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -35,6 +35,8 @@ services:
|
|||||||
- SECRET_KEY=your_secret_key_change_in_production
|
- SECRET_KEY=your_secret_key_change_in_production
|
||||||
- DEBUG=True
|
- DEBUG=True
|
||||||
- ENVIRONMENT=development
|
- ENVIRONMENT=development
|
||||||
|
# Add NODE_ENV for Tailwind
|
||||||
|
- NODE_ENV=production
|
||||||
networks:
|
networks:
|
||||||
- dmarq-network
|
- dmarq-network
|
||||||
ports:
|
ports:
|
||||||
|
|||||||