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:
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}>"
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user