Merge pull request #53 from christianlouis/copilot/refactor-imap-config-storage

Fix CodeQL security alerts and improve mail sources test coverage
This commit is contained in:
Christian Krakau-Louis
2026-03-29 19:30:23 +02:00
committed by GitHub
11 changed files with 1708 additions and 316 deletions
@@ -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")
+2 -1
View File
@@ -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"])
@@ -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(),
}
+214 -55
View File
@@ -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
+53
View File
@@ -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"<MailSource id={self.id} name={self.name!r} method={self.method!r}>"
+2 -2
View File
@@ -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,
}
+1
View File
@@ -32,6 +32,7 @@
<li><a href="/domains">Domains</a></li>
<li><a href="/reports">Reports</a></li>
<li><a href="/upload">Upload</a></li>
<li><a href="/mail-sources">Mail Sources</a></li>
<li><a href="/settings">Settings</a></li>
</ul>
</div>
+514
View File
@@ -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 %}
<div class="grid gap-4 md:gap-8 py-4" x-data="mailSourcesApp()">
<!-- Page header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Mail Sources</h1>
<p class="text-muted-foreground mt-1">
Manage inbox accounts used to automatically retrieve DMARC reports.
Multiple accounts and connection methods (IMAP, POP3, Gmail API) are supported.
</p>
</div>
<button class="btn btn-default btn-md" x-on:click="openAddForm()">
<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">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Add Mail Source
</button>
</div>
<!-- Feedback alerts -->
<template x-if="feedback.message && feedback.type === 'success'">
{% call alert(variant="success") %}
{% call alert_description() %}<span x-text="feedback.message"></span>{% endcall %}
{% endcall %}
</template>
<template x-if="feedback.message && feedback.type === 'error'">
{% call alert(variant="error") %}
{% call alert_description() %}<span x-text="feedback.message"></span>{% endcall %}
{% endcall %}
</template>
<!-- Sources list -->
{% 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() %}
<template x-if="sources.length === 0">
<p class="text-muted-foreground text-sm py-4 text-center">
No mail sources configured yet. Click <strong>Add Mail Source</strong> to get started.
</p>
</template>
<template x-if="sources.length > 0">
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Name</th>
<th>Method</th>
<th>Server</th>
<th>Username</th>
<th>Last Checked</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="source in sources" :key="source.id">
<tr class="hover">
<td x-text="source.name" class="font-medium"></td>
<td>
<span class="badge badge-outline" x-text="source.method"></span>
</td>
<td x-text="source.server ? source.server + ':' + source.port : '—'"></td>
<td x-text="source.username || '—'"></td>
<td x-text="source.last_checked ? new Date(source.last_checked).toLocaleString() : 'Never'"></td>
<td>
<input
type="checkbox"
class="toggle toggle-success toggle-sm"
:checked="source.enabled"
x-on:change="toggleSource(source.id)"
/>
</td>
<td>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-xs" x-on:click="openEditForm(source)" title="Edit">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="btn btn-ghost btn-xs" x-on:click="testSource(source.id)" title="Test connection"
:disabled="testing[source.id]">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<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>
</button>
<button class="btn btn-ghost btn-xs text-error" x-on:click="confirmDelete(source)" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path>
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
{% endcall %}
{% endcall %}
<!-- Add / Edit modal -->
<div x-show="showForm" x-cloak
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
x-on:keydown.escape.window="closeForm()">
<div class="bg-base-100 rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="p-6 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold" x-text="editingId ? 'Edit Mail Source' : 'Add Mail Source'"></h2>
<button class="btn btn-ghost btn-sm btn-circle" x-on:click="closeForm()"></button>
</div>
<form id="source-form" x-on:submit.prevent="saveSource()" class="space-y-4">
<!-- Name -->
<div>
<label class="label"><span class="label-text font-medium">Name <span class="text-error">*</span></span></label>
<input type="text" x-model="form.name" placeholder="e.g. DMARC Reports Inbox"
class="input input-bordered w-full" required />
</div>
<!-- Method -->
<div>
<label class="label"><span class="label-text font-medium">Method</span></label>
<select x-model="form.method" class="select select-bordered w-full">
<option value="IMAP">IMAP</option>
<option value="POP3">POP3 (coming soon)</option>
<option value="GMAIL_API">Gmail API (coming soon)</option>
</select>
</div>
<!-- Server + Port (IMAP / POP3) -->
<template x-if="form.method === 'IMAP' || form.method === 'POP3'">
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="label"><span class="label-text font-medium">Server <span class="text-error">*</span></span></label>
<input type="text" x-model="form.server" placeholder="imap.example.com"
class="input input-bordered w-full" />
</div>
<div>
<label class="label"><span class="label-text font-medium">Port</span></label>
<input type="number" x-model.number="form.port" class="input input-bordered w-full" />
</div>
</div>
</template>
<!-- Username -->
<template x-if="form.method === 'IMAP' || form.method === 'POP3'">
<div>
<label class="label"><span class="label-text font-medium">Username <span class="text-error">*</span></span></label>
<input type="text" x-model="form.username" placeholder="dmarc@example.com"
class="input input-bordered w-full" />
</div>
</template>
<!-- Password -->
<template x-if="form.method === 'IMAP' || form.method === 'POP3'">
<div>
<label class="label">
<span class="label-text font-medium">Password <span class="text-error">*</span></span>
<span class="label-text-alt text-muted-foreground" x-show="editingId">Leave blank to keep existing</span>
</label>
<div class="relative">
<input :type="showPassword ? 'text' : 'password'" x-model="form.password"
class="input input-bordered w-full pr-10" />
<button type="button" class="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
x-on:click="showPassword = !showPassword">
<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>
</div>
</template>
<!-- SSL toggle -->
<template x-if="form.method === 'IMAP' || form.method === 'POP3'">
<div class="flex items-center gap-3">
<input type="checkbox" x-model="form.use_ssl" class="toggle toggle-sm" id="form-ssl" />
<label for="form-ssl" class="label-text">Use SSL/TLS (recommended)</label>
</div>
</template>
<!-- Folder -->
<template x-if="form.method === 'IMAP'">
<div>
<label class="label"><span class="label-text font-medium">Folder</span></label>
<input type="text" x-model="form.folder" placeholder="INBOX"
class="input input-bordered w-full" />
</div>
</template>
<!-- Polling interval -->
<div>
<label class="label"><span class="label-text font-medium">Polling Interval (minutes)</span></label>
<input type="number" x-model.number="form.polling_interval" min="15" max="1440"
class="input input-bordered w-full" />
<p class="text-xs text-muted-foreground mt-1">How often to check for new reports (min 15 min)</p>
</div>
<!-- Enabled -->
<div class="flex items-center gap-3">
<input type="checkbox" x-model="form.enabled" class="toggle toggle-sm" id="form-enabled" />
<label for="form-enabled" class="label-text">Enable this mail source</label>
</div>
<!-- Test connection result -->
<template x-if="testResult.message">
<div :class="testResult.success ? 'alert alert-success' : 'alert alert-error'" class="text-sm p-3 rounded">
<span x-text="testResult.message"></span>
</div>
</template>
<!-- Form actions -->
<div class="flex justify-between pt-2">
<button type="button" class="btn btn-outline btn-sm"
x-on:click="testAdHoc()"
:disabled="isTesting || isSaving">
<span x-show="!isTesting">Test Connection</span>
<span x-show="isTesting" class="flex items-center" x-cloak>
<svg class="animate-spin h-4 w-4 mr-1" 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>
<div class="flex gap-2">
<button type="button" class="btn btn-ghost btn-sm" x-on:click="closeForm()">Cancel</button>
<button type="submit" class="btn btn-default btn-sm" :disabled="isSaving">
<span x-show="!isSaving" x-text="editingId ? 'Update' : 'Save'"></span>
<span x-show="isSaving" x-cloak>Saving…</span>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<!-- Delete confirmation modal -->
<div x-show="deleteTarget" x-cloak
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div class="bg-base-100 rounded-lg shadow-xl w-full max-w-sm p-6 space-y-4">
<h2 class="text-lg font-semibold">Delete Mail Source</h2>
<p class="text-sm text-muted-foreground">
Are you sure you want to delete <strong x-text="deleteTarget && deleteTarget.name"></strong>?
This action cannot be undone.
</p>
<div class="flex justify-end gap-2">
<button class="btn btn-ghost btn-sm" x-on:click="deleteTarget = null">Cancel</button>
<button class="btn btn-error btn-sm" x-on:click="deleteSource()">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function mailSourcesApp() {
return {
sources: [],
showForm: false,
editingId: null,
deleteTarget: null,
showPassword: false,
isTesting: false,
isSaving: false,
testing: {},
feedback: { message: '', type: '' },
testResult: { message: '', success: false },
form: {
name: '',
method: 'IMAP',
server: '',
port: 993,
username: '',
password: '',
use_ssl: true,
folder: 'INBOX',
polling_interval: 60,
enabled: true,
},
async init() {
await this.loadSources();
},
async loadSources() {
try {
const resp = await fetch('/api/v1/mail-sources');
if (resp.ok) {
this.sources = await resp.json();
}
} catch (e) {
console.error('Failed to load mail sources', e);
}
},
openAddForm() {
this.editingId = null;
this.form = {
name: '',
method: 'IMAP',
server: '',
port: 993,
username: '',
password: '',
use_ssl: true,
folder: 'INBOX',
polling_interval: 60,
enabled: true,
};
this.testResult = { message: '', success: false };
this.showForm = true;
},
openEditForm(source) {
this.editingId = source.id;
this.form = {
name: source.name,
method: source.method,
server: source.server || '',
port: source.port || 993,
username: source.username || '',
password: '', // never pre-fill password
use_ssl: source.use_ssl !== false,
folder: source.folder || 'INBOX',
polling_interval: source.polling_interval || 60,
enabled: source.enabled !== false,
};
this.testResult = { message: '', success: false };
this.showForm = true;
},
closeForm() {
this.showForm = false;
this.editingId = null;
this.testResult = { message: '', success: false };
},
async saveSource() {
this.isSaving = true;
this.feedback = { message: '', type: '' };
try {
const payload = { ...this.form };
// Don't send empty password on edit
if (this.editingId && !payload.password) {
delete payload.password;
}
const url = this.editingId
? `/api/v1/mail-sources/${this.editingId}`
: '/api/v1/mail-sources';
const method = this.editingId ? 'PUT' : 'POST';
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Save failed');
}
this.feedback = {
message: this.editingId
? 'Mail source updated successfully.'
: 'Mail source created successfully.',
type: 'success',
};
await this.loadSources();
this.closeForm();
} catch (e) {
this.feedback = { message: `Error: ${e.message}`, type: 'error' };
} finally {
this.isSaving = false;
}
},
async toggleSource(id) {
try {
const resp = await fetch(`/api/v1/mail-sources/${id}/toggle`, { method: 'POST' });
if (!resp.ok) throw new Error('Toggle failed');
await this.loadSources();
} catch (e) {
this.feedback = { message: `Error toggling source: ${e.message}`, type: 'error' };
}
},
confirmDelete(source) {
this.deleteTarget = source;
},
async deleteSource() {
if (!this.deleteTarget) return;
try {
const resp = await fetch(`/api/v1/mail-sources/${this.deleteTarget.id}`, {
method: 'DELETE',
});
if (!resp.ok && resp.status !== 204) throw new Error('Delete failed');
this.feedback = {
message: `Mail source "${this.deleteTarget.name}" deleted.`,
type: 'success',
};
this.deleteTarget = null;
await this.loadSources();
} catch (e) {
this.feedback = { message: `Error: ${e.message}`, type: 'error' };
this.deleteTarget = null;
}
},
async testSource(id) {
this.testing[id] = true;
this.feedback = { message: '', type: '' };
try {
const resp = await fetch(`/api/v1/mail-sources/${id}/test`, { method: 'POST' });
const result = await resp.json();
if (result.success) {
this.feedback = {
message: `Connection test successful for source #${id}: ${result.message}`,
type: 'success',
};
await this.loadSources();
} else {
this.feedback = {
message: `Connection test failed for source #${id}: ${result.message}`,
type: 'error',
};
}
} catch (e) {
this.feedback = { message: `Test error: ${e.message}`, type: 'error' };
} finally {
this.testing[id] = false;
}
},
async testAdHoc() {
this.isTesting = true;
this.testResult = { message: '', success: false };
try {
const payload = {
server: this.form.server,
port: this.form.port,
username: this.form.username,
password: this.form.password,
ssl: this.form.use_ssl,
method: this.form.method,
};
const resp = await fetch('/api/v1/mail-sources/test-connection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await resp.json();
this.testResult = {
success: result.success,
message: result.success
? `✓ Connected. ${result.message_count || 0} messages, ${result.dmarc_count || 0} potential DMARC reports.`
: `${result.message}`,
};
} catch (e) {
this.testResult = { success: false, message: `Error: ${e.message}` };
} finally {
this.isTesting = false;
}
},
};
}
</script>
{% endblock %}
+23 -256
View File
@@ -10,144 +10,26 @@
{% block content %}
<div class="grid gap-4 md:gap-8 py-4">
<!-- IMAP Configuration -->
<!-- Mail Sources info card (replaces the old IMAP configuration form) -->
{% 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
<strong>Mail Sources</strong> page. Multiple accounts and methods
(IMAP, POP3, Gmail API) can be configured there.
{% 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>
<a href="/mail-sources" 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="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
Manage Mail Sources
</a>
{% endcall %}
{% endcall %}
@@ -196,132 +78,17 @@
{% 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() {
// Percent display for DMARC policy form
const percentInput = document.getElementById('percent');
const percentDisplay = document.getElementById('percent-display');
if (percentInput && percentDisplay) {
percentInput.addEventListener('input', function() {
percentDisplay.textContent = this.value + '%';
});
}
// DMARC policy form handling
const policyForm = document.getElementById('dmarc-policy-form');
if (policyForm) {
+39 -2
View File
@@ -4,11 +4,14 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
import app.models.domain # noqa: F401 # pylint: disable=unused-import
import app.models.mail_source as _mail_source_model # noqa: F401 # pylint: disable=unused-import
import app.models.report # noqa: F401 # pylint: disable=unused-import
import app.models.user # noqa: F401 # pylint: disable=unused-import
from app.core.database import Base, get_db
from app.core.security import require_admin_auth
from app.main import create_app
from app.services.report_store import ReportStore
@@ -22,8 +25,17 @@ def test_app() -> FastAPI:
@pytest.fixture()
def db_session():
"""Create a fresh in-memory SQLite database session per test."""
engine = create_engine("sqlite://", connect_args={"check_same_thread": False})
"""Create a fresh in-memory SQLite database session per test.
``StaticPool`` ensures every SQLAlchemy operation reuses the same
underlying DBAPI connection so the in-memory database (and its tables)
persist for the full duration of the test, even across commits.
"""
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = TestingSessionLocal()
@@ -58,3 +70,28 @@ def _reset_report_store():
store.clear()
yield
store.clear()
@pytest.fixture()
def authed_client(test_app: FastAPI, db_session): # pylint: disable=redefined-outer-name
"""
TestClient with both DB and admin-auth dependency overrides.
Bypasses ``require_admin_auth`` so tests can call admin-only endpoints
without needing a real API key or JWT token.
"""
async def mock_admin_auth():
return {"auth_type": "api_key", "api_key": "test-key"}
def override_get_db():
try:
yield db_session
finally:
pass
test_app.dependency_overrides[get_db] = override_get_db
test_app.dependency_overrides[require_admin_auth] = mock_admin_auth
with TestClient(test_app) as test_client:
yield test_client
test_app.dependency_overrides.clear()
+500
View File
@@ -0,0 +1,500 @@
"""
Tests for MailSource model and mail-sources API endpoints.
"""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.mail_source import MailSource
class TestMailSourceModel:
"""Unit tests for the MailSource ORM model."""
def test_create_mail_source(self, db_session: Session):
source = MailSource(
name="Test IMAP",
method="IMAP",
server="imap.example.com",
port=993,
username="user@example.com",
password="secret",
use_ssl=True,
folder="INBOX",
polling_interval=60,
enabled=True,
)
db_session.add(source)
db_session.commit()
db_session.refresh(source)
assert source.id is not None
assert source.name == "Test IMAP"
assert source.method == "IMAP"
assert source.server == "imap.example.com"
assert source.port == 993
assert source.username == "user@example.com"
assert source.password == "secret"
assert source.use_ssl is True
assert source.folder == "INBOX"
assert source.polling_interval == 60
assert source.enabled is True
assert source.last_checked is None
def test_default_values(self, db_session: Session):
source = MailSource(name="Minimal", method="IMAP")
db_session.add(source)
db_session.commit()
db_session.refresh(source)
assert source.folder == "INBOX" # model default
assert source.enabled is True
assert source.last_checked is None
def test_repr(self, db_session: Session):
source = MailSource(name="Demo", method="POP3")
db_session.add(source)
db_session.commit()
db_session.refresh(source)
rep = repr(source)
assert "Demo" in rep
assert "POP3" in rep
def test_multiple_sources(self, db_session: Session):
for i in range(3):
db_session.add(MailSource(name=f"Source {i}", method="IMAP"))
db_session.commit()
all_sources = db_session.query(MailSource).all()
assert len(all_sources) == 3
class TestMailSourcesAPI:
"""Integration tests for /api/v1/mail-sources endpoints (no auth)."""
def test_list_requires_auth(self, client: TestClient):
resp = client.get("/api/v1/mail-sources")
# Without auth, expect 401 or 403
assert resp.status_code in (401, 403)
def test_create_requires_auth(self, client: TestClient):
resp = client.post("/api/v1/mail-sources", json={"name": "x", "method": "IMAP"})
assert resp.status_code in (401, 403)
def test_model_create_and_list(self, client: TestClient, db_session: Session):
"""Create a mail source directly in DB and verify it's retrievable."""
source = MailSource(
name="Direct DB Source",
method="IMAP",
server="imap.example.com",
port=993,
username="user@example.com",
password="secret",
use_ssl=True,
folder="INBOX",
polling_interval=60,
enabled=True,
)
db_session.add(source)
db_session.commit()
db_session.refresh(source)
assert source.id is not None
fetched = db_session.query(MailSource).filter_by(name="Direct DB Source").first()
assert fetched is not None
assert fetched.server == "imap.example.com"
def test_toggle_enabled(self, db_session: Session):
source = MailSource(name="Toggle Test", method="IMAP", enabled=True)
db_session.add(source)
db_session.commit()
db_session.refresh(source)
# Simulate toggle
source.enabled = not source.enabled
db_session.commit()
db_session.refresh(source)
assert source.enabled is False
source.enabled = not source.enabled
db_session.commit()
db_session.refresh(source)
assert source.enabled is True
def test_delete_source(self, db_session: Session):
source = MailSource(name="To Delete", method="IMAP")
db_session.add(source)
db_session.commit()
sid = source.id
db_session.delete(source)
db_session.commit()
fetched = db_session.query(MailSource).filter_by(id=sid).first()
assert fetched is None
def test_query_enabled_sources(self, db_session: Session):
db_session.add(MailSource(name="Enabled A", method="IMAP", enabled=True))
db_session.add(MailSource(name="Enabled B", method="IMAP", enabled=True))
db_session.add(MailSource(name="Disabled", method="IMAP", enabled=False))
db_session.commit()
enabled = db_session.query(MailSource).filter(MailSource.enabled).all()
assert len(enabled) == 2
names = {s.name for s in enabled}
assert "Enabled A" in names
assert "Enabled B" in names
assert "Disabled" not in names
# ---------------------------------------------------------------------------
# Authenticated HTTP API tests (uses authed_client fixture from conftest)
# ---------------------------------------------------------------------------
class TestMailSourcesAPIAuthed:
"""HTTP-level tests using the authed_client fixture (auth dependency bypassed)."""
# ------------------------------------------------------------------
# List
# ------------------------------------------------------------------
def test_list_empty(self, authed_client: TestClient):
resp = authed_client.get("/api/v1/mail-sources")
assert resp.status_code == 200
assert resp.json() == []
# ------------------------------------------------------------------
# Create
# ------------------------------------------------------------------
def test_create_imap_source(self, authed_client: TestClient):
payload = {
"name": "My IMAP",
"method": "IMAP",
"server": "imap.example.com",
"port": 993,
"username": "user@example.com",
"password": "s3cr3t",
"use_ssl": True,
"folder": "INBOX",
"polling_interval": 60,
"enabled": True,
}
resp = authed_client.post("/api/v1/mail-sources", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "My IMAP"
assert data["method"] == "IMAP"
assert data["server"] == "imap.example.com"
assert data["password"] == "**redacted**"
assert data["id"] is not None
def test_create_normalizes_method_to_uppercase(self, authed_client: TestClient):
payload = {"name": "lowercase method", "method": "imap"}
resp = authed_client.post("/api/v1/mail-sources", json=payload)
assert resp.status_code == 201
assert resp.json()["method"] == "IMAP"
# ------------------------------------------------------------------
# Get single
# ------------------------------------------------------------------
def test_get_existing_source(self, authed_client: TestClient):
create_resp = authed_client.post(
"/api/v1/mail-sources", json={"name": "Get Test", "method": "IMAP"}
)
source_id = create_resp.json()["id"]
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}")
assert resp.status_code == 200
assert resp.json()["id"] == source_id
assert resp.json()["name"] == "Get Test"
def test_get_nonexistent_source_returns_404(self, authed_client: TestClient):
resp = authed_client.get("/api/v1/mail-sources/99999")
assert resp.status_code == 404
# ------------------------------------------------------------------
# Update
# ------------------------------------------------------------------
def test_update_name(self, authed_client: TestClient):
create_resp = authed_client.post(
"/api/v1/mail-sources", json={"name": "Original", "method": "IMAP"}
)
source_id = create_resp.json()["id"]
update_resp = authed_client.put(
f"/api/v1/mail-sources/{source_id}", json={"name": "Updated"}
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "Updated"
def test_update_method_normalizes_uppercase(self, authed_client: TestClient):
create_resp = authed_client.post(
"/api/v1/mail-sources", json={"name": "MethodTest", "method": "IMAP"}
)
source_id = create_resp.json()["id"]
update_resp = authed_client.put(
f"/api/v1/mail-sources/{source_id}", json={"method": "pop3"}
)
assert update_resp.status_code == 200
assert update_resp.json()["method"] == "POP3"
def test_update_nonexistent_source_returns_404(self, authed_client: TestClient):
resp = authed_client.put("/api/v1/mail-sources/99999", json={"name": "x"})
assert resp.status_code == 404
# ------------------------------------------------------------------
# Delete
# ------------------------------------------------------------------
def test_delete_source(self, authed_client: TestClient):
create_resp = authed_client.post(
"/api/v1/mail-sources", json={"name": "Delete Me", "method": "IMAP"}
)
source_id = create_resp.json()["id"]
del_resp = authed_client.delete(f"/api/v1/mail-sources/{source_id}")
assert del_resp.status_code == 204
# Verify gone
get_resp = authed_client.get(f"/api/v1/mail-sources/{source_id}")
assert get_resp.status_code == 404
def test_delete_nonexistent_source_returns_404(self, authed_client: TestClient):
resp = authed_client.delete("/api/v1/mail-sources/99999")
assert resp.status_code == 404
# ------------------------------------------------------------------
# List after creates
# ------------------------------------------------------------------
def test_list_multiple_sources(self, authed_client: TestClient):
for i in range(3):
authed_client.post(
"/api/v1/mail-sources", json={"name": f"Source {i}", "method": "IMAP"}
)
resp = authed_client.get("/api/v1/mail-sources")
assert resp.status_code == 200
assert len(resp.json()) == 3
# ------------------------------------------------------------------
# Toggle
# ------------------------------------------------------------------
def test_toggle_disables_then_enables(self, authed_client: TestClient):
create_resp = authed_client.post(
"/api/v1/mail-sources", json={"name": "Toggle", "method": "IMAP", "enabled": True}
)
source_id = create_resp.json()["id"]
toggle_resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/toggle")
assert toggle_resp.status_code == 200
assert toggle_resp.json()["enabled"] is False
toggle_resp2 = authed_client.post(f"/api/v1/mail-sources/{source_id}/toggle")
assert toggle_resp2.status_code == 200
assert toggle_resp2.json()["enabled"] is True
def test_toggle_nonexistent_returns_404(self, authed_client: TestClient):
resp = authed_client.post("/api/v1/mail-sources/99999/toggle")
assert resp.status_code == 404
# ------------------------------------------------------------------
# Test stored source
# ------------------------------------------------------------------
def test_test_stored_imap_source_success(self, authed_client: TestClient):
create_resp = authed_client.post(
"/api/v1/mail-sources",
json={
"name": "IMAP Test",
"method": "IMAP",
"server": "imap.example.com",
"username": "u",
"password": "p",
},
)
source_id = create_resp.json()["id"]
mock_stats = {
"message_count": 10,
"unread_count": 2,
"dmarc_count": 1,
"available_mailboxes": ["INBOX"],
}
mock_client = MagicMock()
mock_client.test_connection.return_value = (True, "Connection successful", mock_stats)
with patch("app.api.api_v1.endpoints.mail_sources.IMAPClient", return_value=mock_client):
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/test")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["message"] == "Connection successful"
assert data["message_count"] == 10
def test_test_stored_imap_source_failure(self, authed_client: TestClient):
create_resp = authed_client.post(
"/api/v1/mail-sources",
json={"name": "IMAP Fail", "method": "IMAP", "server": "bad.host"},
)
source_id = create_resp.json()["id"]
mock_client = MagicMock()
mock_client.test_connection.return_value = (
False,
"Connection failed. Check server address and credentials.",
{},
)
with patch("app.api.api_v1.endpoints.mail_sources.IMAPClient", return_value=mock_client):
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/test")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
def test_test_stored_non_imap_source(self, authed_client: TestClient):
create_resp = authed_client.post(
"/api/v1/mail-sources", json={"name": "POP3 Source", "method": "POP3"}
)
source_id = create_resp.json()["id"]
resp = authed_client.post(f"/api/v1/mail-sources/{source_id}/test")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "not yet implemented" in data["message"]
def test_test_stored_nonexistent_returns_404(self, authed_client: TestClient):
resp = authed_client.post("/api/v1/mail-sources/99999/test")
assert resp.status_code == 404
# ------------------------------------------------------------------
# Ad-hoc test connection
# ------------------------------------------------------------------
def test_adhoc_imap_success(self, authed_client: TestClient):
payload = {
"server": "imap.example.com",
"port": 993,
"username": "user@example.com",
"password": "secret",
"ssl": True,
"method": "IMAP",
}
mock_stats = {
"message_count": 5,
"unread_count": 1,
"dmarc_count": 0,
"available_mailboxes": ["INBOX"],
}
mock_client = MagicMock()
mock_client.test_connection.return_value = (True, "Connection successful", mock_stats)
with patch("app.api.api_v1.endpoints.mail_sources.IMAPClient", return_value=mock_client):
resp = authed_client.post("/api/v1/mail-sources/test-connection", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["message_count"] == 5
def test_adhoc_non_imap_returns_not_implemented(self, authed_client: TestClient):
payload = {
"server": "pop3.example.com",
"port": 110,
"username": "u",
"password": "p",
"ssl": False,
"method": "POP3",
}
resp = authed_client.post("/api/v1/mail-sources/test-connection", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "not yet implemented" in data["message"]
def test_adhoc_gmail_api_returns_not_implemented(self, authed_client: TestClient):
payload = {"method": "GMAIL_API"}
resp = authed_client.post("/api/v1/mail-sources/test-connection", json=payload)
assert resp.status_code == 200
assert resp.json()["success"] is False
assert "not yet implemented" in resp.json()["message"]
# ---------------------------------------------------------------------------
# _sanitize_for_log helper
# ---------------------------------------------------------------------------
class TestSanitizeForLog:
"""Unit tests for the _sanitize_for_log helper."""
def test_strips_newline(self):
from app.api.api_v1.endpoints.mail_sources import _sanitize_for_log
assert "\n" not in _sanitize_for_log("hello\nworld")
def test_strips_carriage_return(self):
from app.api.api_v1.endpoints.mail_sources import _sanitize_for_log
assert "\r" not in _sanitize_for_log("foo\rbar")
def test_integer_is_safe(self):
from app.api.api_v1.endpoints.mail_sources import _sanitize_for_log
assert _sanitize_for_log(42) == "42"
def test_normal_string_unchanged(self):
from app.api.api_v1.endpoints.mail_sources import _sanitize_for_log
assert _sanitize_for_log("example.com") == "example.com"
# ---------------------------------------------------------------------------
# Source-to-response helper (password masking)
# ---------------------------------------------------------------------------
class TestSourceToResponse:
"""Tests for the _source_to_response password-masking helper."""
def test_password_is_redacted_when_set(self, db_session: Session):
from app.api.api_v1.endpoints.mail_sources import _source_to_response
source = MailSource(name="Redact Test", method="IMAP", password="plaintext")
db_session.add(source)
db_session.commit()
db_session.refresh(source)
response = _source_to_response(source)
assert response.password == "**redacted**"
def test_password_is_none_when_not_set(self, db_session: Session):
from app.api.api_v1.endpoints.mail_sources import _source_to_response
source = MailSource(name="No Password", method="IMAP")
db_session.add(source)
db_session.commit()
db_session.refresh(source)
response = _source_to_response(source)
assert response.password is None
# ---------------------------------------------------------------------------
# Pytest marker to avoid warnings for test methods without assertions
# ---------------------------------------------------------------------------
pytestmark = pytest.mark.usefixtures("_reset_report_store")