Merge pull request #98 from christianlouis/codex/mail-source-import-history

feat: add mail source import history
This commit is contained in:
Christian Krakau-Louis
2026-05-22 19:30:13 +02:00
committed by GitHub
14 changed files with 375 additions and 13 deletions
+2
View File
@@ -25,6 +25,8 @@ if database_url:
# Import all models so that autogenerate can detect them
from app.core.database import Base # noqa: E402
import app.models.domain # noqa: E402, F401
import app.models.mail_source # noqa: E402, F401
import app.models.mail_source_import # noqa: E402, F401
import app.models.report # noqa: E402, F401
import app.models.setting # noqa: E402, F401
import app.models.user # noqa: E402, F401
@@ -0,0 +1,73 @@
"""add mail source import history
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-05-22 19:40:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "e5f6a7b8c9d0"
down_revision: Union[str, Sequence[str], None] = "d4e5f6a7b8c9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create the mail_source_imports table."""
op.create_table(
"mail_source_imports",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("mail_source_id", sa.Integer(), nullable=False),
sa.Column("trigger", sa.String(), nullable=False, server_default="manual"),
sa.Column("status", sa.String(), nullable=False),
sa.Column("processed", sa.Integer(), nullable=False, server_default="0"),
sa.Column("reports_found", sa.Integer(), nullable=False, server_default="0"),
sa.Column("duplicate_reports", sa.Integer(), nullable=False, server_default="0"),
sa.Column("error_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("new_domains", sa.Text(), nullable=True),
sa.Column("errors", sa.Text(), nullable=True),
sa.Column("started_at", sa.DateTime(), nullable=False),
sa.Column("finished_at", sa.DateTime(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["mail_source_id"], ["mail_sources.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_mail_source_imports_id"), "mail_source_imports", ["id"])
op.create_index(
op.f("ix_mail_source_imports_mail_source_id"),
"mail_source_imports",
["mail_source_id"],
)
op.create_index(
op.f("ix_mail_source_imports_status"),
"mail_source_imports",
["status"],
)
op.create_index(
op.f("ix_mail_source_imports_started_at"),
"mail_source_imports",
["started_at"],
)
op.create_index(
op.f("ix_mail_source_imports_finished_at"),
"mail_source_imports",
["finished_at"],
)
def downgrade() -> None:
"""Drop the mail_source_imports table."""
op.drop_index(op.f("ix_mail_source_imports_finished_at"), table_name="mail_source_imports")
op.drop_index(op.f("ix_mail_source_imports_started_at"), table_name="mail_source_imports")
op.drop_index(op.f("ix_mail_source_imports_status"), table_name="mail_source_imports")
op.drop_index(
op.f("ix_mail_source_imports_mail_source_id"),
table_name="mail_source_imports",
)
op.drop_index(op.f("ix_mail_source_imports_id"), table_name="mail_source_imports")
op.drop_table("mail_source_imports")
@@ -7,6 +7,7 @@ persisting anything. Gmail API sources additionally have OAuth2 helper
endpoints (authorize-url, callback, fetch).
"""
import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -18,8 +19,10 @@ 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.models.mail_source_import import MailSourceImport
from app.services.gmail_client import GmailClient
from app.services.imap_client import IMAPClient
from app.services.import_history import record_import_attempt
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -105,6 +108,24 @@ class GmailCallbackRequest(BaseModel):
redirect_uri: str
class MailSourceImportResponse(BaseModel):
"""Sanitized import-history entry for a mail source."""
id: int
mail_source_id: int
trigger: str
status: str
processed: int
reports_found: int
duplicate_reports: int
error_count: int
new_domains: List[str]
errors: List[str]
started_at: datetime
finished_at: datetime
created_at: datetime
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
@@ -149,6 +170,38 @@ def _source_to_response(source: MailSource) -> MailSourceResponse:
)
def _decode_json_list(value: Optional[str]) -> List[str]:
"""Decode a JSON list stored on import history rows."""
if not value:
return []
try:
decoded = json.loads(value)
except (json.JSONDecodeError, TypeError):
return []
if not isinstance(decoded, list):
return []
return [str(item) for item in decoded]
def _import_to_response(row: MailSourceImport) -> MailSourceImportResponse:
"""Convert an import-history ORM row to an API response."""
return MailSourceImportResponse(
id=row.id,
mail_source_id=row.mail_source_id,
trigger=row.trigger,
status=row.status,
processed=row.processed,
reports_found=row.reports_found,
duplicate_reports=row.duplicate_reports,
error_count=row.error_count,
new_domains=_decode_json_list(row.new_domains),
errors=_decode_json_list(row.errors),
started_at=row.started_at,
finished_at=row.finished_at,
created_at=row.created_at,
)
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@@ -205,6 +258,26 @@ async def get_mail_source(
return _source_to_response(source)
@router.get("/{source_id}/imports", response_model=List[MailSourceImportResponse])
async def list_mail_source_imports(
source_id: int,
limit: int = 20,
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> List[MailSourceImportResponse]:
"""Return recent sanitized import attempts for one mail source."""
_get_source_or_404(source_id, db)
safe_limit = min(max(limit, 1), 100)
rows = (
db.query(MailSourceImport)
.filter(MailSourceImport.mail_source_id == source_id)
.order_by(MailSourceImport.started_at.desc(), MailSourceImport.id.desc())
.limit(safe_limit)
.all()
)
return [_import_to_response(row) for row in rows]
@router.put("/{source_id}", response_model=MailSourceResponse)
async def update_mail_source(
source_id: int,
@@ -608,6 +681,7 @@ async def gmail_fetch_reports(
already_ingested_ids=already,
)
started_at = datetime.utcnow()
results = client.fetch_reports()
# Persist updated ingested IDs and any refreshed tokens
@@ -622,6 +696,7 @@ async def gmail_fetch_reports(
source.gmail_refresh_token = refreshed["refresh_token"]
source.last_checked = datetime.utcnow()
record_import_attempt(db, source, results, started_at=started_at, trigger="manual")
db.commit()
logger.info(
+10
View File
@@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import app.models.domain # noqa: F401 ensure Domain/UserDomain tables are registered
import app.models.mail_source_import # noqa: F401 ensure import history table is registered
import app.models.report # noqa: F401 ensure DMARCReport/ReportRecord tables are registered
import app.models.setting # noqa: F401 ensure Setting table is registered
import app.models.user # noqa: F401 ensure User table is registered
@@ -22,6 +23,7 @@ from app.middleware.security import SecurityHeadersMiddleware
from app.models.mail_source import MailSource # noqa: F401 ensure table is registered
from app.services.gmail_client import GmailClient
from app.services.imap_client import IMAPClient
from app.services.import_history import record_import_attempt
from app.services.report_store import ReportStore
# Set up logging
@@ -45,6 +47,7 @@ def _poll_single_imap_source(source: MailSource) -> None:
password=source.password,
delete_emails=False,
)
started_at = datetime.utcnow()
results = imap_client.fetch_reports(days=9999)
db = SessionLocal()
@@ -52,6 +55,7 @@ def _poll_single_imap_source(source: MailSource) -> None:
src = db.query(MailSource).get(source.id)
if src:
src.last_checked = datetime.utcnow()
record_import_attempt(db, src, results, started_at=started_at, trigger="scheduled")
db.commit()
finally:
db.close()
@@ -95,6 +99,7 @@ def _poll_single_gmail_source(source: MailSource) -> None:
already_ingested_ids=already,
)
started_at = datetime.utcnow()
results = client.fetch_reports()
db = SessionLocal()
@@ -112,6 +117,7 @@ def _poll_single_gmail_source(source: MailSource) -> None:
src.gmail_refresh_token = refreshed["refresh_token"]
src.last_checked = datetime.utcnow()
record_import_attempt(db, src, results, started_at=started_at, trigger="scheduled")
db.commit()
finally:
db.close()
@@ -517,9 +523,11 @@ def _trigger_poll_imap_source(source: MailSource, db) -> dict:
password=source.password,
delete_emails=False,
)
started_at = datetime.utcnow()
results = imap_client.fetch_reports(days=7)
last_check_time = datetime.now()
source.last_checked = datetime.utcnow()
record_import_attempt(db, source, results, started_at=started_at, trigger="manual")
db.commit()
return {
"source_id": source.id,
@@ -543,6 +551,7 @@ def _trigger_poll_gmail_source(source: MailSource, db) -> dict:
refresh_token=source.gmail_refresh_token or "",
already_ingested_ids=already,
)
started_at = datetime.utcnow()
results = gmail_client.fetch_reports()
last_check_time = datetime.now()
@@ -555,6 +564,7 @@ def _trigger_poll_gmail_source(source: MailSource, db) -> dict:
if "refresh_token" in refreshed:
source.gmail_refresh_token = refreshed["refresh_token"]
source.last_checked = datetime.utcnow()
record_import_attempt(db, source, results, started_at=started_at, trigger="manual")
db.commit()
return {
"source_id": source.id,
+7
View File
@@ -1,6 +1,7 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -63,5 +64,11 @@ class MailSource(Base):
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
imports = relationship(
"MailSourceImport",
back_populates="mail_source",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<MailSource id={self.id} name={self.name!r} method={self.method!r}>"
+38
View File
@@ -0,0 +1,38 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import relationship
from app.core.database import Base
class MailSourceImport(Base):
"""Sanitized audit record for one mail source import attempt."""
__tablename__ = "mail_source_imports"
id = Column(Integer, primary_key=True, index=True)
mail_source_id = Column(Integer, ForeignKey("mail_sources.id"), nullable=False, index=True)
trigger = Column(String, nullable=False, default="manual")
status = Column(String, nullable=False, index=True)
processed = Column(Integer, nullable=False, default=0)
reports_found = Column(Integer, nullable=False, default=0)
duplicate_reports = Column(Integer, nullable=False, default=0)
error_count = Column(Integer, nullable=False, default=0)
new_domains = Column(Text, nullable=True)
errors = Column(Text, nullable=True)
started_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
finished_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
mail_source = relationship("MailSource", back_populates="imports")
def __repr__(self):
return (
f"<MailSourceImport id={self.id} source={self.mail_source_id} "
f"status={self.status!r}>"
)
+3
View File
@@ -207,6 +207,7 @@ class GmailClient:
"success": True,
"processed": 0,
"reports_found": 0,
"duplicate_reports": 0,
"new_domains": [],
"errors": [],
"new_ingested_ids": [],
@@ -365,6 +366,8 @@ class GmailClient:
if self._store_report_if_new(report):
stats["reports_found"] += 1
reports_found += 1
else:
stats["duplicate_reports"] = stats.get("duplicate_reports", 0) + 1
except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Failed to parse DMARC attachment %s: %s", filename, exc)
stats["errors"].append(f"Failed to parse {filename}: {exc}")
+7 -2
View File
@@ -146,7 +146,7 @@ class IMAPClient:
msg = email.message_from_bytes(raw_email)
if self._is_dmarc_report_email(msg):
reports_found = self._process_attachments(msg)
reports_found = self._process_attachments(msg, stats)
stats["reports_found"] += reports_found
# Mark email as read (and optionally delete)
@@ -178,6 +178,7 @@ class IMAPClient:
"success": True,
"processed": 0,
"reports_found": 0,
"duplicate_reports": 0,
"new_domains": [],
"errors": [],
}
@@ -350,7 +351,7 @@ class IMAPClient:
return False
def _process_attachments(self, msg: email.message.Message) -> int:
def _process_attachments(self, msg: email.message.Message, stats: dict | None = None) -> int:
"""
Process email attachments that might be DMARC reports
@@ -394,6 +395,10 @@ class IMAPClient:
report_id,
domain,
)
if stats is not None:
stats["duplicate_reports"] = (
stats.get("duplicate_reports", 0) + 1
)
continue
# Add the report to the store
+54
View File
@@ -0,0 +1,54 @@
import json
from datetime import datetime
from typing import Any, Dict, Iterable, Optional
from sqlalchemy.orm import Session
from app.models.mail_source import MailSource
from app.models.mail_source_import import MailSourceImport
MAX_STORED_ERRORS = 10
MAX_ERROR_LENGTH = 500
def _sanitize_error(value: object) -> str:
"""Return a compact, log-safe error string for storage and UI display."""
text = str(value).replace("\r", "").replace("\n", " ").strip()
if len(text) > MAX_ERROR_LENGTH:
return text[: MAX_ERROR_LENGTH - 1] + "..."
return text
def _json_list(values: Optional[Iterable[Any]]) -> str:
return json.dumps([str(value) for value in values or []])
def record_import_attempt(
db: Session,
source: MailSource,
results: Dict[str, Any],
*,
started_at: datetime,
trigger: str,
) -> MailSourceImport:
"""Persist a sanitized summary of a mail source import attempt."""
result_errors = list(results.get("errors") or [])
errors = [_sanitize_error(error) for error in result_errors[:MAX_STORED_ERRORS]]
success = bool(results.get("success", False))
status = "success" if success and not errors else "warning" if success else "failed"
attempt = MailSourceImport(
mail_source_id=source.id,
trigger=trigger,
status=status,
processed=int(results.get("processed", 0) or 0),
reports_found=int(results.get("reports_found", 0) or 0),
duplicate_reports=int(results.get("duplicate_reports", 0) or 0),
error_count=len(result_errors),
new_domains=_json_list(results.get("new_domains", [])),
errors=json.dumps(errors),
started_at=started_at,
finished_at=datetime.utcnow(),
)
db.add(attempt)
return attempt
+1
View File
@@ -8,6 +8,7 @@ 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.mail_source_import # noqa: F401 # pylint: disable=unused-import
import app.models.report # noqa: F401 # pylint: disable=unused-import
import app.models.setting # noqa: F401 # pylint: disable=unused-import
import app.models.user # noqa: F401 # pylint: disable=unused-import
+67
View File
@@ -11,6 +11,7 @@ from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from app.models.mail_source import MailSource
from app.models.mail_source_import import MailSourceImport
class TestMailSourceModel:
@@ -75,6 +76,36 @@ class TestMailSourceModel:
assert len(all_sources) == 3
class TestMailSourceImportModel:
"""Unit tests for persisted mail source import history."""
def test_create_import_history_row(self, db_session: Session):
source = MailSource(name="History Source", method="GMAIL_API")
db_session.add(source)
db_session.commit()
db_session.refresh(source)
row = MailSourceImport(
mail_source_id=source.id,
trigger="manual",
status="warning",
processed=3,
reports_found=2,
duplicate_reports=1,
error_count=1,
new_domains='["example.com"]',
errors='["bad attachment"]',
)
db_session.add(row)
db_session.commit()
db_session.refresh(row)
assert row.id is not None
assert row.mail_source_id == source.id
assert row.duplicate_reports == 1
assert row.mail_source.name == "History Source"
class TestMailSourcesAPI:
"""Integration tests for /api/v1/mail-sources endpoints (no auth)."""
@@ -219,6 +250,42 @@ class TestMailSourcesAPIAuthed:
assert resp.json()["id"] == source_id
assert resp.json()["name"] == "Get Test"
def test_list_import_history(self, authed_client: TestClient, db_session: Session):
create_resp = authed_client.post(
"/api/v1/mail-sources", json={"name": "History API", "method": "IMAP"}
)
source_id = create_resp.json()["id"]
db_session.add(
MailSourceImport(
mail_source_id=source_id,
trigger="manual",
status="warning",
processed=2,
reports_found=1,
duplicate_reports=1,
error_count=1,
new_domains='["example.com"]',
errors='["sanitized error"]',
)
)
db_session.commit()
resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/imports")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["mail_source_id"] == source_id
assert data[0]["status"] == "warning"
assert data[0]["duplicate_reports"] == 1
assert data[0]["new_domains"] == ["example.com"]
assert data[0]["errors"] == ["sanitized error"]
def test_list_import_history_unknown_source_returns_404(self, authed_client: TestClient):
resp = authed_client.get("/api/v1/mail-sources/99999/imports")
assert resp.status_code == 404
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
+21 -4
View File
@@ -12,7 +12,7 @@ Complete:
- Upload validation and archive safety checks.
- IMAP mailbox ingestion.
- Gmail OAuth ingestion.
- Persistent database models and migrations.
- Persistent database models and migrations for domains, reports, records, settings, users, and mail sources.
- Domain, report, settings, and mail source APIs.
- Dashboard and domain detail views.
- Logto auth integration and local development auth-disabled mode.
@@ -23,13 +23,30 @@ Recently improved:
- Gmail import now uses the same parser path as uploads and IMAP.
- Gmail and IMAP imports now skip duplicate domain/report IDs.
- Tests cover Google-style DMARC ZIP attachment imports.
- Mail source imports now create sanitized import-history records for manual and scheduled polls.
## Active Milestone: Reporting Quality and Import Confidence
Important gap:
- Parsed DMARC report data is still served primarily from the in-memory `ReportStore`. The database schema exists, but report upload/import paths and dashboard read paths must be completed before the persistence milestone can be called done.
## Active Milestone: Finish Report Persistence
Objective: complete the database-backed report storage promised by Milestone 3.
Priority tasks:
- Add a report persistence service that converts parsed DMARC report dicts into `Domain`, `DMARCReport`, and `ReportRecord` rows.
- Load or query persisted reports for dashboard, domain, and report endpoints.
- Add duplicate report detection against the database.
- Keep tests covering upload, Gmail import, IMAP import, and restart-style reload behavior.
Quality bar:
- Uploading or importing a report survives application restart and remains visible in report/domain endpoints.
## Next Milestone: Reporting Quality and Import Confidence
Objective: make mailbox imports auditable and make report totals trustworthy.
Priority tasks:
- Persist import attempts with message ID, source, attachment filename, outcome, and sanitized error details.
- Expand import attempts with message ID, source, attachment filename, outcome, and sanitized error details.
- Show import history on mail source detail pages.
- Report duplicate skips separately from parse failures.
- Add backfill controls for Gmail and IMAP sources.
@@ -40,7 +57,7 @@ Quality bar:
- Parse failures must be visible and actionable.
- The user should be able to tell whether a mail source is healthy without reading logs.
## Next Milestone: Meaningful Reports
## Following Milestone: Meaningful Reports
Objective: turn parsed DMARC data into administrator-friendly reports.
+11 -4
View File
@@ -29,19 +29,25 @@ Delivered:
- Duplicate report protection for both Gmail and IMAP imports.
- Background polling and manual poll hooks for configured mail sources.
## Milestone 3: Persistence, Domain Management, and Auth Foundation - Complete
## Milestone 3: Database Foundation, Domain Management, and Auth Foundation - In Progress
Status: Complete
Status: In progress
Delivered:
- SQLAlchemy models and Alembic migrations.
- SQLite/PostgreSQL-compatible database configuration.
- Domain management APIs and UI.
- Report and source persistence.
- Report and source database models.
- Settings and mail source persistence.
- Logto-based auth integration plus an explicit local development auth-disabled mode.
- Security middleware, safer default secret generation, and security-focused tests.
Remaining before this milestone is complete:
- Persist parsed DMARC reports and report records through the database-backed models.
- Load or query persisted reports after restart so dashboards do not depend on process memory.
- Move report/domain summary endpoints from the in-memory `ReportStore` to database queries.
- Keep duplicate report detection consistent across upload, IMAP, and Gmail after persistence is enabled.
## Milestone 4: Reporting Quality and Import Confidence - In Progress
Status: In progress
@@ -52,10 +58,11 @@ Recently delivered:
- Gmail import now handles real inbox metadata patterns and Google-style ZIP filenames.
- Gmail/IMAP imports skip duplicate report IDs to avoid inflated totals.
- Tests now cover a real Google-style ZIP attachment path rather than only mocked parser behavior.
- Mail source imports now persist sanitized import-history records for manual and scheduled polls.
Next tasks:
- Finish Milestone 3 report persistence before expanding report features.
- Add per-import result details: skipped duplicates, parse failures, unsupported attachments, and imported report IDs.
- Store failed import attempts with enough metadata to retry or diagnose them without exposing secrets.
- Add a UI import history view for each mail source.
- Add mailbox search controls for date range/backfill without requiring code changes.
- Improve source aggregation so each sender IP keeps pass/fail totals instead of only the latest result.
+6 -3
View File
@@ -88,8 +88,8 @@ This file tracks the specific implementation tasks for each milestone of the DMA
### Duplicate and Error Handling
- [x] Skip duplicate domain/report IDs during Gmail imports
- [x] Skip duplicate domain/report IDs during IMAP imports
- [ ] Persist sanitized import errors for UI review
- [ ] Count duplicate skips separately from parse failures
- [x] Persist sanitized import errors for API/UI review
- [x] Count duplicate skips separately from parse failures
- [ ] Add retry/backfill controls per mail source
## Milestone 3: Database Integration
@@ -101,11 +101,14 @@ This file tracks the specific implementation tasks for each milestone of the DMA
- [x] Implement data access layer
### Model Migration
- [x] Convert in-memory models to database models
- [ ] Convert report ingestion and dashboard reads from in-memory storage to database-backed storage
- [x] Create Domain table
- [x] Create AggregateReport table
- [x] Create ReportRecord table for sender details
- [x] Implement relationships between models
- [ ] Persist parsed upload reports to `dmarc_reports` and `report_records`
- [ ] Persist parsed Gmail/IMAP reports to `dmarc_reports` and `report_records`
- [ ] Load/query persisted reports after app restart
### Domain Management
- [x] Create UI for adding/editing domains