diff --git a/backend/alembic/versions/add_mail_sources.py b/backend/alembic/versions/add_mail_sources.py new file mode 100644 index 0000000..4e81dbe --- /dev/null +++ b/backend/alembic/versions/add_mail_sources.py @@ -0,0 +1,50 @@ +"""add mail_sources table + +Revision ID: a1b2c3d4e5f6 +Revises: 88b549786e2d +Create Date: 2026-03-29 17:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "88b549786e2d" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create the mail_sources table.""" + op.create_table( + "mail_sources", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("method", sa.String(), nullable=False), + sa.Column("server", sa.String(), nullable=True), + sa.Column("port", sa.Integer(), nullable=True), + sa.Column("username", sa.String(), nullable=True), + sa.Column("password", sa.Text(), nullable=True), + sa.Column("use_ssl", sa.Boolean(), nullable=True), + sa.Column("folder", sa.String(), nullable=True), + sa.Column("polling_interval", sa.Integer(), nullable=True), + sa.Column("enabled", sa.Boolean(), nullable=True), + sa.Column("last_checked", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_mail_sources_id"), "mail_sources", ["id"], unique=False) + op.create_index( + op.f("ix_mail_sources_enabled"), "mail_sources", ["enabled"], unique=False + ) + + +def downgrade() -> None: + """Drop the mail_sources table.""" + op.drop_index(op.f("ix_mail_sources_enabled"), table_name="mail_sources") + op.drop_index(op.f("ix_mail_sources_id"), table_name="mail_sources") + op.drop_table("mail_sources") diff --git a/backend/app/api/api_v1/api.py b/backend/app/api/api_v1/api.py index 0341762..1408430 100644 --- a/backend/app/api/api_v1/api.py +++ b/backend/app/api/api_v1/api.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.api_v1.endpoints import domains, health, imap, reports, setup, stats +from app.api.api_v1.endpoints import domains, health, imap, mail_sources, reports, setup, stats api_router = APIRouter() @@ -11,3 +11,4 @@ api_router.include_router(reports.router, prefix="/reports", tags=["reports"]) api_router.include_router(setup.router, prefix="/setup", tags=["setup"]) api_router.include_router(imap.router, prefix="/imap", tags=["imap"]) api_router.include_router(stats.router, prefix="/stats", tags=["stats"]) +api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"]) diff --git a/backend/app/api/api_v1/endpoints/mail_sources.py b/backend/app/api/api_v1/endpoints/mail_sources.py new file mode 100644 index 0000000..5b7ddf8 --- /dev/null +++ b/backend/app/api/api_v1/endpoints/mail_sources.py @@ -0,0 +1,310 @@ +""" +Mail Sources API endpoints. + +Provides CRUD operations for MailSource objects stored in the database, plus +a *test-connection* action that validates the supplied credentials without +persisting anything. +""" + +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.security import require_admin_auth +from app.models.mail_source import MailSource +from app.services.imap_client import IMAPClient + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + + +class MailSourceBase(BaseModel): + """Fields shared by create and update payloads.""" + + name: str + method: str = "IMAP" # IMAP | POP3 | GMAIL_API + server: Optional[str] = None + port: int = 993 + username: Optional[str] = None + password: Optional[str] = None + use_ssl: bool = True + folder: str = "INBOX" + polling_interval: int = 60 + enabled: bool = True + + +class MailSourceCreate(MailSourceBase): + """Payload for creating a new mail source.""" + + +class MailSourceUpdate(BaseModel): + """Payload for partial updates – all fields optional.""" + + name: Optional[str] = None + method: Optional[str] = None + server: Optional[str] = None + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + use_ssl: Optional[bool] = None + folder: Optional[str] = None + polling_interval: Optional[int] = None + enabled: Optional[bool] = None + + +class MailSourceResponse(MailSourceBase): + """Response schema – exposes the stored row without exposing raw password.""" + + id: int + last_checked: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + # Mask the stored password in responses + password: Optional[str] = None + + class Config: + from_attributes = True + + +class TestConnectionRequest(BaseModel): + """Credentials for an ad-hoc connection test (not persisted).""" + + server: Optional[str] = None + port: int = 993 + username: Optional[str] = None + password: Optional[str] = None + ssl: bool = True + method: str = "IMAP" + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _sanitize_for_log(value: object) -> str: + """Remove CR/LF characters from a value to prevent log injection attacks.""" + return str(value).replace("\r", "").replace("\n", " ") + + +def _get_source_or_404(source_id: int, db: Session) -> MailSource: + source = db.query(MailSource).filter(MailSource.id == source_id).first() + if source is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mail source {source_id} not found", + ) + return source + + +def _source_to_response(source: MailSource) -> MailSourceResponse: + """Convert ORM row to response schema, masking the stored password.""" + return MailSourceResponse( + id=source.id, + name=source.name, + method=source.method, + server=source.server, + port=source.port or 993, + username=source.username, + password="**redacted**" if source.password else None, + use_ssl=source.use_ssl if source.use_ssl is not None else True, + folder=source.folder or "INBOX", + polling_interval=source.polling_interval or 60, + enabled=source.enabled if source.enabled is not None else True, + last_checked=source.last_checked, + created_at=source.created_at, + updated_at=source.updated_at, + ) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@router.get("", response_model=List[MailSourceResponse]) +async def list_mail_sources( + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> List[MailSourceResponse]: + """Return all configured mail sources (passwords redacted).""" + sources = db.query(MailSource).order_by(MailSource.id).all() + return [_source_to_response(s) for s in sources] + + +@router.post("", response_model=MailSourceResponse, status_code=status.HTTP_201_CREATED) +async def create_mail_source( + payload: MailSourceCreate, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> MailSourceResponse: + """Create a new mail source.""" + source = MailSource( + name=payload.name, + method=payload.method.upper(), + server=payload.server, + port=payload.port, + username=payload.username, + password=payload.password, + use_ssl=payload.use_ssl, + folder=payload.folder, + polling_interval=payload.polling_interval, + enabled=payload.enabled, + ) + db.add(source) + db.commit() + db.refresh(source) + logger.info( + "Created mail source id=%d name=%r method=%r", source.id, source.name, source.method + ) + return _source_to_response(source) + + +@router.get("/{source_id}", response_model=MailSourceResponse) +async def get_mail_source( + source_id: int, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> MailSourceResponse: + """Return a single mail source by ID (password redacted).""" + source = _get_source_or_404(source_id, db) + return _source_to_response(source) + + +@router.put("/{source_id}", response_model=MailSourceResponse) +async def update_mail_source( + source_id: int, + payload: MailSourceUpdate, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> MailSourceResponse: + """Update one or more fields of an existing mail source.""" + source = _get_source_or_404(source_id, db) + + update_data = payload.model_dump(exclude_unset=True) + if "method" in update_data and update_data["method"]: + update_data["method"] = update_data["method"].upper() + + for field, value in update_data.items(): + setattr(source, field, value) + + source.updated_at = datetime.utcnow() + db.commit() + db.refresh(source) + logger.info("Updated mail source id=%d", source.id) + return _source_to_response(source) + + +@router.delete("/{source_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_mail_source( + source_id: int, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> None: + """Delete a mail source permanently.""" + source = _get_source_or_404(source_id, db) + db.delete(source) + db.commit() + logger.info("Deleted mail source id=%s", _sanitize_for_log(source_id)) + + +@router.post("/{source_id}/toggle", response_model=MailSourceResponse) +async def toggle_mail_source( + source_id: int, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> MailSourceResponse: + """Toggle the *enabled* flag of a mail source.""" + source = _get_source_or_404(source_id, db) + source.enabled = not source.enabled + source.updated_at = datetime.utcnow() + db.commit() + db.refresh(source) + return _source_to_response(source) + + +@router.post("/{source_id}/test", response_model=Dict[str, Any]) +async def test_stored_mail_source( + source_id: int, + db: Session = Depends(get_db), + _auth: dict = Depends(require_admin_auth), +) -> Dict[str, Any]: + """Test the connection for an already-stored mail source using its saved credentials.""" + source = _get_source_or_404(source_id, db) + + if source.method != "IMAP": + return { + "success": False, + "message": f"Connection testing for method '{source.method}' is not yet implemented.", + "timestamp": datetime.now().isoformat(), + } + + imap_client = IMAPClient( + server=source.server, + port=source.port or 993, + username=source.username, + password=source.password, + ) + success, message, stats = imap_client.test_connection() + + if success: + source.last_checked = datetime.utcnow() + db.commit() + + return { + "success": success, + "message": message, + "message_count": stats.get("message_count", 0), + "unread_count": stats.get("unread_count", 0), + "dmarc_count": stats.get("dmarc_count", 0), + "available_mailboxes": stats.get("available_mailboxes", []), + "timestamp": datetime.now().isoformat(), + } + + +@router.post("/test-connection", response_model=Dict[str, Any]) +async def test_connection_adhoc( + request: TestConnectionRequest, + _auth: dict = Depends(require_admin_auth), +) -> Dict[str, Any]: + """ + Test a connection using ad-hoc credentials (not stored in the database). + + Useful when filling out the *add/edit mail source* form before saving. + """ + method = request.method.upper() + + if method != "IMAP": + return { + "success": False, + "message": f"Connection testing for method '{method}' is not yet implemented.", + "timestamp": datetime.now().isoformat(), + } + + imap_client = IMAPClient( + server=request.server, + port=request.port, + username=request.username, + password=request.password, + ) + success, message, stats = imap_client.test_connection() + + return { + "success": success, + "message": message, + "message_count": stats.get("message_count", 0), + "unread_count": stats.get("unread_count", 0), + "dmarc_count": stats.get("dmarc_count", 0), + "available_mailboxes": stats.get("available_mailboxes", []), + "timestamp": datetime.now().isoformat(), + } diff --git a/backend/app/main.py b/backend/app/main.py index 76ebefe..65809a1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,8 +11,10 @@ from fastapi.templating import Jinja2Templates from app.api.api_v1.api import api_router from app.core.config import get_settings +from app.core.database import Base, SessionLocal, engine from app.core.security import add_api_key, generate_api_key, require_admin_auth from app.middleware.security import SecurityHeadersMiddleware +from app.models.mail_source import MailSource # noqa: F401 – ensure table is registered from app.services.imap_client import IMAPClient from app.services.report_store import ReportStore @@ -26,49 +28,149 @@ background_task = None last_check_time = None -async def scheduled_imap_polling(): - """Background task for periodically checking IMAP for new DMARC reports""" +def _poll_single_imap_source(source: MailSource) -> None: + """Fetch DMARC reports for a single IMAP mail source and update its last_checked timestamp.""" global last_check_time # pylint: disable=global-statement - try: - # How often to check for emails (in seconds) - check_interval = 3600 # Default: 1 hour + imap_client = IMAPClient( + server=source.server, + port=source.port or 993, + username=source.username, + password=source.password, + delete_emails=False, + ) + results = imap_client.fetch_reports(days=9999) + db = SessionLocal() + try: + src = db.query(MailSource).get(source.id) + if src: + src.last_checked = datetime.utcnow() + db.commit() + finally: + db.close() + + last_check_time = datetime.now() + + if results["success"]: + logger.info( + "IMAP polling (source id=%d): %s emails processed, %s reports found", + source.id, + results["processed"], + results["reports_found"], + ) + if results["new_domains"]: + logger.info("New domains found: %s", ", ".join(results["new_domains"])) + else: + logger.error( + "IMAP polling (source id=%d) failed: %s", + source.id, + results.get("error", "Unknown error"), + ) + + +def _poll_all_enabled_sources() -> None: + """Iterate over all enabled mail sources and poll each one.""" + db = SessionLocal() + try: + enabled_sources = ( + db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712 + ) + finally: + db.close() + + if not enabled_sources: + logger.info("No enabled mail sources configured – polling skipped") + return + + for source in enabled_sources: + if source.method != "IMAP": + logger.info( + "Skipping mail source id=%d method=%r (not yet implemented)", + source.id, + source.method, + ) + continue + try: + _poll_single_imap_source(source) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error polling mail source id=%d: %s", source.id, str(e)) + + +def _next_sleep_seconds(min_sleep: int = 60) -> int: + """Return how many seconds to sleep until the next polling cycle.""" + try: + db = SessionLocal() + try: + intervals = [ + s.polling_interval or 60 + for s in db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712 + ] + finally: + db.close() + return max(min_sleep, min(intervals, default=3600) * 60) + except Exception: # pylint: disable=broad-exception-caught + return 3600 + + +async def scheduled_imap_polling(): + """Background task for periodically checking IMAP for new DMARC reports""" + try: 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( - "IMAP polling completed: %s emails processed, %s reports found", - results["processed"], - results["reports_found"], - ) - - # If new domains were found, log them - if results["new_domains"]: - logger.info("New domains found: %s", ", ".join(results["new_domains"])) - - else: - logger.error("IMAP polling failed: %s", results.get("error", "Unknown error")) - + _poll_all_enabled_sources() except Exception as e: # pylint: disable=broad-exception-caught logger.error("Error in IMAP polling task: %s", str(e)) - # Wait for the next check interval - await asyncio.sleep(check_interval) + await asyncio.sleep(_next_sleep_seconds()) except asyncio.CancelledError: logger.info("IMAP polling task cancelled") +def _migrate_imap_env_vars_to_db() -> None: + """ + One-time migration: if IMAP_* environment variables are configured and no + MailSource rows exist yet, create an initial MailSource from those settings. + + This ensures that existing deployments continue to work without manual + reconfiguration after the upgrade. + """ + if not all([settings.IMAP_SERVER, settings.IMAP_USERNAME, settings.IMAP_PASSWORD]): + return + + db = SessionLocal() + try: + if db.query(MailSource).first() is not None: + return # already migrated or manually configured + + migrated = MailSource( + name="Default IMAP (migrated from environment)", + method="IMAP", + server=settings.IMAP_SERVER, + port=settings.IMAP_PORT, + username=settings.IMAP_USERNAME, + password=settings.IMAP_PASSWORD, + use_ssl=True, + folder="INBOX", + polling_interval=60, + enabled=True, + ) + db.add(migrated) + db.commit() + logger.info( + "Migrated IMAP settings from environment variables to " + "database (MailSource id=%d). " + "You can now manage this source via the Mail Sources admin UI.", + migrated.id, + ) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Failed to migrate IMAP env vars to database: %s", str(e)) + finally: + db.close() + + def create_app() -> FastAPI: """Create and configure the FastAPI application""" application = FastAPI( @@ -120,6 +222,9 @@ def create_app() -> FastAPI: """Initialize background tasks and security on application startup""" global background_task # pylint: disable=global-statement + # Ensure all tables exist (no-op if already present) + Base.metadata.create_all(bind=engine) + # Generate and provide admin API key api_key = generate_api_key() add_api_key(api_key) @@ -141,12 +246,14 @@ def create_app() -> FastAPI: if os.getenv("ENVIRONMENT", "development") == "development": logger.info("Development Mode - Full API Key: %s", api_key) - # 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") + # One-time migration: if IMAP_* env vars are set and no mail sources exist, + # create an initial MailSource from those settings so existing deployments + # continue to work without manual reconfiguration. + _migrate_imap_env_vars_to_db() + + # Start background polling task (iterates over DB-enabled mail sources) + logger.info("Starting IMAP polling background task") + background_task = asyncio.create_task(scheduled_imap_polling()) @application.on_event("shutdown") async def shutdown_event(): @@ -239,6 +346,11 @@ async def settings_page(request: Request): return templates.TemplateResponse("settings.html", {"request": request}) +@app.get("/mail-sources", response_class=HTMLResponse) +async def mail_sources_page(request: Request): + return templates.TemplateResponse("mail_sources.html", {"request": request}) + + @app.get("/upload", response_class=HTMLResponse) async def upload_page(request: Request): return templates.TemplateResponse("upload.html", {"request": request}) @@ -255,34 +367,81 @@ async def health(): @app.post("/api/v1/admin/trigger-poll") async def trigger_imap_poll(auth: dict = Depends(require_admin_auth)): """ - Manually trigger IMAP polling (admin only - requires authentication) + Manually trigger IMAP polling for all enabled mail sources (admin only). Security: Requires either X-API-Key header or Bearer token """ global last_check_time # pylint: disable=global-statement + results_summary = [] + db = SessionLocal() try: - # Create IMAP client and fetch reports - imap_client = IMAPClient(delete_emails=False) - results = imap_client.fetch_reports(days=7) + enabled_sources = ( + db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712 + ) - # Update last check time - last_check_time = datetime.now() + if not enabled_sources: + return { + "success": True, + "message": "No enabled mail sources configured.", + "sources_polled": 0, + "authenticated_by": auth.get("auth_type"), + } - return { - "success": results["success"], - "timestamp": last_check_time.isoformat(), - "processed": results["processed"], - "reports_found": results["reports_found"], - "new_domains": results["new_domains"], - "authenticated_by": auth.get("auth_type"), - } - except Exception as e: # pylint: disable=broad-exception-caught - logger.error("Error triggering IMAP poll: %s", str(e)) - return { - "success": False, - "error": "Failed to trigger IMAP poll. Check server logs for details.", - } + for source in enabled_sources: + if source.method != "IMAP": + results_summary.append( + { + "source_id": source.id, + "name": source.name, + "skipped": True, + "reason": f"method '{source.method}' not yet implemented", + } + ) + continue + + try: + imap_client = IMAPClient( + server=source.server, + port=source.port or 993, + username=source.username, + password=source.password, + delete_emails=False, + ) + results = imap_client.fetch_reports(days=7) + last_check_time = datetime.now() + source.last_checked = datetime.utcnow() + db.commit() + + results_summary.append( + { + "source_id": source.id, + "name": source.name, + "success": results["success"], + "processed": results.get("processed", 0), + "reports_found": results.get("reports_found", 0), + "new_domains": results.get("new_domains", []), + } + ) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error polling mail source id=%d: %s", source.id, str(e)) + results_summary.append( + { + "source_id": source.id, + "name": source.name, + "success": False, + "error": "Failed to poll. Check server logs for details.", + } + ) + finally: + db.close() + + return { + "success": all(r.get("success", True) for r in results_summary), + "timestamp": last_check_time.isoformat() if last_check_time else None, + "sources": results_summary, + "authenticated_by": auth.get("auth_type"), + } # API endpoint to check status of IMAP polling diff --git a/backend/app/models/mail_source.py b/backend/app/models/mail_source.py new file mode 100644 index 0000000..75fde33 --- /dev/null +++ b/backend/app/models/mail_source.py @@ -0,0 +1,53 @@ +from datetime import datetime + +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text + +from app.core.database import Base + + +class MailSource(Base): + """ + Mail source configuration model. + + Stores credentials and settings for a mail inbox used to retrieve DMARC + aggregate reports. The ``method`` field determines how the connection is + made and which additional fields are relevant: + + - ``IMAP`` – standard IMAP4 (over SSL/TLS or STARTTLS) + - ``POP3`` – POP3 inbox (stub for future implementation) + - ``GMAIL_API`` – Gmail API with OAuth 2.0 (stub for future implementation) + """ + + __tablename__ = "mail_sources" + + id = Column(Integer, primary_key=True, index=True) + + # Human-readable label for the source + name = Column(String, nullable=False) + + # Connection method – determines which fields are used at runtime + method = Column(String, nullable=False, default="IMAP") # IMAP | POP3 | GMAIL_API + + # Connection details (used by IMAP and POP3) + server = Column(String, nullable=True) + port = Column(Integer, nullable=True, default=993) + username = Column(String, nullable=True) + # NOTE: password is stored in plaintext. In a production environment this + # field should be encrypted at the application layer before persisting. + password = Column(Text, nullable=True) + use_ssl = Column(Boolean, default=True) + folder = Column(String, default="INBOX") + + # Polling behaviour + polling_interval = Column(Integer, default=60) # minutes + + # Source lifecycle + enabled = Column(Boolean, default=True, index=True) + last_checked = Column(DateTime, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def __repr__(self): + return f"" diff --git a/backend/app/services/imap_client.py b/backend/app/services/imap_client.py index fd73b3b..2eb4b98 100644 --- a/backend/app/services/imap_client.py +++ b/backend/app/services/imap_client.py @@ -132,7 +132,7 @@ class IMAPClient: return True, "Connection successful", stats except Exception as e: # pylint: disable=broad-exception-caught logger.error("IMAP connection test failed: %s", str(e)) - return False, f"Connection failed: {str(e)}", {} + return False, "Connection failed. Check server address and credentials.", {} def _process_single_email(self, mail, email_id: bytes, stats: dict) -> None: """Fetch, parse, and store DMARC attachments from one email message.""" @@ -229,7 +229,7 @@ class IMAPClient: logger.error("Error fetching DMARC reports: %s", str(e)) return { "success": False, - "error": f"Error connecting to mailbox: {str(e)}", + "error": "Error connecting to mailbox. Check server logs for details.", "processed": 0, } diff --git a/backend/app/templates/layouts/base.html b/backend/app/templates/layouts/base.html index 9d15ab9..25dda64 100644 --- a/backend/app/templates/layouts/base.html +++ b/backend/app/templates/layouts/base.html @@ -32,6 +32,7 @@
  • Domains
  • Reports
  • Upload
  • +
  • Mail Sources
  • Settings
  • diff --git a/backend/app/templates/mail_sources.html b/backend/app/templates/mail_sources.html new file mode 100644 index 0000000..40da181 --- /dev/null +++ b/backend/app/templates/mail_sources.html @@ -0,0 +1,514 @@ +{% 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 %}Mail Sources - DMARQ{% endblock %} + +{% block page_title %}Mail Sources{% endblock %} + +{% block content %} +
    + + +
    +
    +

    Mail Sources

    +

    + Manage inbox accounts used to automatically retrieve DMARC reports. + Multiple accounts and connection methods (IMAP, POP3, Gmail API) are supported. +

    +
    + +
    + + + + + + + {% call card() %} + {% call card_header() %} + {% call card_title() %}Configured Accounts{% endcall %} + {% call card_description() %}Click a row to edit. Use the toggle to enable or disable polling for an account.{% endcall %} + {% endcall %} + {% call card_content() %} + + + {% endcall %} + {% endcall %} + + +
    +
    +
    +
    +

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

    How often to check for new reports (min 15 min)

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

    Delete Mail Source

    +

    + Are you sure you want to delete ? + This action cannot be undone. +

    +
    + + +
    +
    +
    + +
    +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/backend/app/templates/settings.html b/backend/app/templates/settings.html index ca23c70..7ea908e 100644 --- a/backend/app/templates/settings.html +++ b/backend/app/templates/settings.html @@ -10,144 +10,26 @@ {% block content %}
    - + + {% call card() %} {% call card_header() %} - {% call card_title() %}IMAP Configuration{% endcall %} + {% call card_title() %}Mail Sources{% endcall %} {% call card_description() %} - Configure the IMAP connection to automatically retrieve DMARC reports from your email + IMAP and other inbox credentials are now managed on the dedicated + Mail Sources page. Multiple accounts and methods + (IMAP, POP3, Gmail API) can be configured there. {% endcall %} {% endcall %} {% call card_content() %} -
    -
    -
    - {% call form_group() %} - {% call label(for="imap_server", required=True) %}IMAP Server{% endcall %} - {{ input(type="text", name="imap_server", id="imap_server", placeholder="mail.example.com", required=True) }} - {% endcall %} - - {% call form_group() %} - {% call label(for="imap_port", required=True) %}IMAP Port{% endcall %} - {{ input(type="number", name="imap_port", id="imap_port", value="993", required=True) }} - {% endcall %} - - {% call form_group() %} - {% call label(for="imap_ssl") %}Use SSL{% endcall %} -
    - - -
    - {% endcall %} -
    - -
    - {% call form_group() %} - {% call label(for="imap_username", required=True) %}IMAP Username{% endcall %} - {{ input(type="text", name="imap_username", id="imap_username", placeholder="dmarc-reports@example.com", required=True) }} - {% endcall %} - - {% call form_group() %} - {% call label(for="imap_password", required=True) %}IMAP Password{% endcall %} -
    - {{ input(type="password", name="imap_password", id="imap_password", required=True) }} - -
    - {% endcall %} - - {% call form_group() %} - {% call label(for="polling_interval") %}Polling Interval (minutes){% endcall %} - {{ input(type="number", name="polling_interval", id="polling_interval", value="60", min="15", max="1440") }} -

    How often to check for new reports (minimum 15 minutes)

    - {% endcall %} -
    -
    - -
    - - - -
    - -
    - - - -
    - -
    - - -
    -
    + + + + + + Manage Mail Sources + {% endcall %} {% endcall %} @@ -196,132 +78,17 @@ {% block scripts %}