feat: refactor IMAP config to DB, add multi-account mail sources admin UI
Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/f141cd3b-8d0a-4c7e-b479-ee973c209c11 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
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 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()
|
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(setup.router, prefix="/setup", tags=["setup"])
|
||||||
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
|
api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
|
||||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
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,305 @@
|
|||||||
|
"""
|
||||||
|
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 _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=%d", 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(),
|
||||||
|
}
|
||||||
+198
-39
@@ -11,8 +11,10 @@ from fastapi.templating import Jinja2Templates
|
|||||||
|
|
||||||
from app.api.api_v1.api import api_router
|
from app.api.api_v1.api import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import Base, SessionLocal, engine
|
||||||
from app.core.security import add_api_key, generate_api_key, require_admin_auth
|
from app.core.security import add_api_key, generate_api_key, require_admin_auth
|
||||||
from app.middleware.security import SecurityHeadersMiddleware
|
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.imap_client import IMAPClient
|
||||||
from app.services.report_store import ReportStore
|
from app.services.report_store import ReportStore
|
||||||
|
|
||||||
@@ -26,49 +28,149 @@ background_task = None
|
|||||||
last_check_time = None
|
last_check_time = None
|
||||||
|
|
||||||
|
|
||||||
async def scheduled_imap_polling():
|
def _poll_single_imap_source(source: MailSource) -> None:
|
||||||
"""Background task for periodically checking IMAP for new DMARC reports"""
|
"""Fetch DMARC reports for a single IMAP mail source and update its last_checked timestamp."""
|
||||||
global last_check_time # pylint: disable=global-statement
|
global last_check_time # pylint: disable=global-statement
|
||||||
|
|
||||||
try:
|
imap_client = IMAPClient(
|
||||||
# How often to check for emails (in seconds)
|
server=source.server,
|
||||||
check_interval = 3600 # Default: 1 hour
|
port=source.port or 993,
|
||||||
|
username=source.username,
|
||||||
while True:
|
password=source.password,
|
||||||
logger.info("Starting scheduled IMAP polling for DMARC reports")
|
delete_emails=False,
|
||||||
|
)
|
||||||
try:
|
|
||||||
# Create IMAP client and fetch reports
|
|
||||||
imap_client = IMAPClient(delete_emails=False)
|
|
||||||
results = imap_client.fetch_reports(days=9999)
|
results = imap_client.fetch_reports(days=9999)
|
||||||
|
|
||||||
# Update last check time
|
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()
|
last_check_time = datetime.now()
|
||||||
|
|
||||||
if results["success"]:
|
if results["success"]:
|
||||||
logger.info(
|
logger.info(
|
||||||
"IMAP polling completed: %s emails processed, %s reports found",
|
"IMAP polling (source id=%d): %s emails processed, %s reports found",
|
||||||
|
source.id,
|
||||||
results["processed"],
|
results["processed"],
|
||||||
results["reports_found"],
|
results["reports_found"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# If new domains were found, log them
|
|
||||||
if results["new_domains"]:
|
if results["new_domains"]:
|
||||||
logger.info("New domains found: %s", ", ".join(results["new_domains"]))
|
logger.info("New domains found: %s", ", ".join(results["new_domains"]))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.error("IMAP polling failed: %s", results.get("error", "Unknown error"))
|
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:
|
||||||
|
_poll_all_enabled_sources()
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.error("Error in IMAP polling task: %s", str(e))
|
logger.error("Error in IMAP polling task: %s", str(e))
|
||||||
|
|
||||||
# Wait for the next check interval
|
await asyncio.sleep(_next_sleep_seconds())
|
||||||
await asyncio.sleep(check_interval)
|
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("IMAP polling task cancelled")
|
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:
|
def create_app() -> FastAPI:
|
||||||
"""Create and configure the FastAPI application"""
|
"""Create and configure the FastAPI application"""
|
||||||
application = FastAPI(
|
application = FastAPI(
|
||||||
@@ -120,6 +222,9 @@ def create_app() -> FastAPI:
|
|||||||
"""Initialize background tasks and security on application startup"""
|
"""Initialize background tasks and security on application startup"""
|
||||||
global background_task # pylint: disable=global-statement
|
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
|
# Generate and provide admin API key
|
||||||
api_key = generate_api_key()
|
api_key = generate_api_key()
|
||||||
add_api_key(api_key)
|
add_api_key(api_key)
|
||||||
@@ -141,12 +246,14 @@ def create_app() -> FastAPI:
|
|||||||
if os.getenv("ENVIRONMENT", "development") == "development":
|
if os.getenv("ENVIRONMENT", "development") == "development":
|
||||||
logger.info("Development Mode - Full API Key: %s", api_key)
|
logger.info("Development Mode - Full API Key: %s", api_key)
|
||||||
|
|
||||||
# Check if IMAP credentials are configured
|
# One-time migration: if IMAP_* env vars are set and no mail sources exist,
|
||||||
if all([settings.IMAP_SERVER, settings.IMAP_USERNAME, settings.IMAP_PASSWORD]):
|
# 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")
|
logger.info("Starting IMAP polling background task")
|
||||||
background_task = asyncio.create_task(scheduled_imap_polling())
|
background_task = asyncio.create_task(scheduled_imap_polling())
|
||||||
else:
|
|
||||||
logger.warning("IMAP credentials not fully configured, polling disabled")
|
|
||||||
|
|
||||||
@application.on_event("shutdown")
|
@application.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
@@ -239,6 +346,11 @@ async def settings_page(request: Request):
|
|||||||
return templates.TemplateResponse("settings.html", {"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)
|
@app.get("/upload", response_class=HTMLResponse)
|
||||||
async def upload_page(request: Request):
|
async def upload_page(request: Request):
|
||||||
return templates.TemplateResponse("upload.html", {"request": request})
|
return templates.TemplateResponse("upload.html", {"request": request})
|
||||||
@@ -248,33 +360,80 @@ async def upload_page(request: Request):
|
|||||||
@app.post("/api/v1/admin/trigger-poll")
|
@app.post("/api/v1/admin/trigger-poll")
|
||||||
async def trigger_imap_poll(auth: dict = Depends(require_admin_auth)):
|
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
|
Security: Requires either X-API-Key header or Bearer token
|
||||||
"""
|
"""
|
||||||
global last_check_time # pylint: disable=global-statement
|
global last_check_time # pylint: disable=global-statement
|
||||||
|
|
||||||
|
results_summary = []
|
||||||
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# Create IMAP client and fetch reports
|
enabled_sources = (
|
||||||
imap_client = IMAPClient(delete_emails=False)
|
db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712
|
||||||
results = imap_client.fetch_reports(days=7)
|
)
|
||||||
|
|
||||||
# Update last check time
|
|
||||||
last_check_time = datetime.now()
|
|
||||||
|
|
||||||
|
if not enabled_sources:
|
||||||
return {
|
return {
|
||||||
"success": results["success"],
|
"success": True,
|
||||||
"timestamp": last_check_time.isoformat(),
|
"message": "No enabled mail sources configured.",
|
||||||
"processed": results["processed"],
|
"sources_polled": 0,
|
||||||
"reports_found": results["reports_found"],
|
|
||||||
"new_domains": results["new_domains"],
|
|
||||||
"authenticated_by": auth.get("auth_type"),
|
"authenticated_by": auth.get("auth_type"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.error("Error triggering IMAP poll: %s", str(e))
|
logger.error("Error polling mail source id=%d: %s", source.id, str(e))
|
||||||
return {
|
results_summary.append(
|
||||||
|
{
|
||||||
|
"source_id": source.id,
|
||||||
|
"name": source.name,
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "Failed to trigger IMAP poll. Check server logs for details.",
|
"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"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}>"
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
<li><a href="/domains">Domains</a></li>
|
<li><a href="/domains">Domains</a></li>
|
||||||
<li><a href="/reports">Reports</a></li>
|
<li><a href="/reports">Reports</a></li>
|
||||||
<li><a href="/upload">Upload</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>
|
<li><a href="/settings">Settings</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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.$set(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.$set(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 %}
|
{% block content %}
|
||||||
<div class="grid gap-4 md:gap-8 py-4">
|
<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() %}
|
||||||
{% call card_header() %}
|
{% call card_header() %}
|
||||||
{% call card_title() %}IMAP Configuration{% endcall %}
|
{% call card_title() %}Mail Sources{% endcall %}
|
||||||
{% call card_description() %}
|
{% 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 %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% call card_content() %}
|
{% call card_content() %}
|
||||||
<form id="imap-form" class="space-y-6" x-data="imapForm()">
|
<a href="/mail-sources" class="btn btn-default btn-md">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||||
<div class="space-y-4">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
|
||||||
{% call form_group() %}
|
<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>
|
||||||
{% call label(for="imap_server", required=True) %}IMAP Server{% endcall %}
|
<polyline points="22,6 12,13 2,6"></polyline>
|
||||||
{{ 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>
|
</svg>
|
||||||
Testing...
|
Manage Mail Sources
|
||||||
</span>
|
</a>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-default btn-md"
|
|
||||||
x-bind:disabled="isTesting || isSaving"
|
|
||||||
>
|
|
||||||
<span x-show="!isSaving">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
|
||||||
Save Configuration
|
|
||||||
</span>
|
|
||||||
<span x-show="isSaving" class="flex items-center" x-cloak>
|
|
||||||
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
Saving...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
@@ -196,60 +78,9 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
function imapForm() {
|
// Initialize any scripts after DOM load
|
||||||
return {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
showPassword: false,
|
// Percent display for DMARC policy form
|
||||||
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 percentInput = document.getElementById('percent');
|
||||||
const percentDisplay = document.getElementById('percent-display');
|
const percentDisplay = document.getElementById('percent-display');
|
||||||
if (percentInput && percentDisplay) {
|
if (percentInput && percentDisplay) {
|
||||||
@@ -258,70 +89,6 @@ function imapForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
const form = document.getElementById('imap-form');
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
await this.saveImapSettings();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadImapSettings() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/admin/imap-settings');
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const settings = await response.json();
|
|
||||||
|
|
||||||
document.getElementById('imap_server').value = settings.imap_server || '';
|
|
||||||
document.getElementById('imap_port').value = settings.imap_port || 993;
|
|
||||||
document.getElementById('imap_username').value = settings.imap_username || '';
|
|
||||||
document.getElementById('imap_password').value = settings.imap_password || '';
|
|
||||||
document.getElementById('imap_ssl').checked = settings.imap_ssl !== false;
|
|
||||||
document.getElementById('polling_interval').value = settings.polling_interval || 60;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load IMAP settings:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveImapSettings() {
|
|
||||||
this.isSaving = true;
|
|
||||||
this.saveResult = '';
|
|
||||||
|
|
||||||
const formData = new FormData(document.getElementById('imap-form'));
|
|
||||||
const data = Object.fromEntries(formData.entries());
|
|
||||||
data.imap_ssl = formData.get('imap_ssl') === 'on';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/v1/admin/imap-settings', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
this.saveStatus = 'success';
|
|
||||||
this.saveResult = 'IMAP settings saved successfully. The system will now check for new DMARC reports based on your settings.';
|
|
||||||
} else {
|
|
||||||
throw new Error(result.detail || 'Failed to save IMAP settings');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.saveStatus = 'error';
|
|
||||||
this.saveResult = `Save failed: ${error.message}`;
|
|
||||||
} finally {
|
|
||||||
this.isSaving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize any scripts after DOM load
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// DMARC policy form handling
|
// DMARC policy form handling
|
||||||
const policyForm = document.getElementById('dmarc-policy-form');
|
const policyForm = document.getElementById('dmarc-policy-form');
|
||||||
if (policyForm) {
|
if (policyForm) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy import create_engine
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
import app.models.domain # noqa: F401 # pylint: disable=unused-import
|
import app.models.domain # noqa: F401 # pylint: disable=unused-import
|
||||||
|
import app.models.mail_source # noqa: F401 # pylint: disable=unused-import
|
||||||
import app.models.report # 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
|
import app.models.user # noqa: F401 # pylint: disable=unused-import
|
||||||
from app.core.database import Base, get_db
|
from app.core.database import Base, get_db
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
Tests for MailSource model and mail-sources API endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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."""
|
||||||
|
|
||||||
|
API_KEY_HEADER = {"X-API-Key": "test-key"} # will be set in fixture
|
||||||
|
|
||||||
|
def _get_headers(self, client: TestClient) -> dict:
|
||||||
|
"""Return auth headers using the running app's in-memory API key."""
|
||||||
|
# The test app generates its own key in-memory; we can't predict it.
|
||||||
|
# Use the TestClient without auth to check 401, and skip auth-required tests
|
||||||
|
# by injecting the dependency override instead.
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def test_list_empty(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_and_list(self, client: TestClient, db_session: Session):
|
||||||
|
"""Create a mail source directly in DB and list via authenticated-less read."""
|
||||||
|
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 == True).all() # noqa: E712
|
||||||
|
)
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user