Files
gh-christianlouis-dmarq/backend/app/services/mail_connector.py
T
2026-05-23 15:57:34 +02:00

165 lines
4.9 KiB
Python

"""Shared contracts and helpers for mail-source connectors."""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Optional, Protocol
from app.core.redaction import redact_sensitive_text
MAX_CONNECTOR_ERROR_LENGTH = 500
@dataclass(frozen=True)
class ConnectorImportContext:
"""Safe import context that can be returned to operators and import history."""
source_type: str
mailbox: Optional[str] = None
folder: Optional[str] = None
search_window_days: Optional[int] = None
def as_stats(self) -> Dict[str, Any]:
stats: Dict[str, Any] = {"source_type": self.source_type}
if self.mailbox:
stats["target_mailbox"] = self.mailbox
if self.folder:
stats["target_folder"] = self.folder
if self.search_window_days is not None:
stats["search_window_days"] = self.search_window_days
return stats
@dataclass(frozen=True)
class ConnectorMessage:
"""Provider-neutral message metadata used by connector implementations."""
message_id: str
subject: str = ""
sender: str = ""
received_at: Optional[str] = None
has_attachments: bool = False
raw: Any = None
@dataclass(frozen=True)
class ConnectorAttachment:
"""Provider-neutral attachment payload used by connector implementations."""
filename: str
content: bytes
content_type: str = ""
raw: Any = None
class MailSourceConnector(Protocol):
"""Interface new mailbox connectors should satisfy before endpoint wiring."""
def import_context(self, days: Optional[int] = None) -> ConnectorImportContext:
"""Return safe, non-secret context for history and API responses."""
def search_messages(self, days: int) -> Iterable[Any]:
"""Return provider messages in the requested search window."""
def iter_attachments(self, message: Any) -> Iterable[Any]:
"""Yield provider attachments for one message."""
def fetch_reports(self, days: int = 7) -> Dict[str, Any]:
"""Fetch, parse, and persist DMARC reports."""
def clamp_search_window(days: Optional[int], *, default: int = 7, maximum: int = 365) -> int:
"""Normalize user-supplied backfill windows for connector fetches."""
try:
value = int(days or default)
except (TypeError, ValueError):
value = default
return max(1, min(value, maximum))
def initial_import_stats(
context: Optional[ConnectorImportContext] = None,
*,
deleted: bool = False,
) -> Dict[str, Any]:
"""Return the shared import-result shape used by mailbox connectors."""
stats: Dict[str, Any] = {
"success": True,
"processed": 0,
"reports_found": 0,
"forensic_reports_found": 0,
"duplicate_reports": 0,
"duplicate_forensic_reports": 0,
"new_domains": [],
"errors": [],
"new_ingested_ids": [],
"details": [],
}
if deleted:
stats["deleted"] = 0
if context:
stats.update(context.as_stats())
return stats
def append_import_detail(
stats: Optional[Dict[str, Any]],
*,
context: Optional[ConnectorImportContext] = None,
**detail: Any,
) -> None:
"""Append one compact, sanitized message or attachment outcome."""
if stats is None:
return
if context:
detail.setdefault("mailbox", context.mailbox)
detail.setdefault("folder", context.folder)
clean_detail = {
str(key): sanitize_connector_error(value)
for key, value in detail.items()
if value not in (None, "")
}
if clean_detail:
stats.setdefault("details", []).append(clean_detail)
def sanitize_connector_error(value: object) -> str:
"""Return a compact, log-safe connector diagnostic with secrets redacted."""
text = redact_sensitive_text(value).strip()
if len(text) > MAX_CONNECTOR_ERROR_LENGTH:
return text[: MAX_CONNECTOR_ERROR_LENGTH - 3] + "..."
return text
def connector_failure_stats(
stats: Dict[str, Any],
message: str,
*,
error: Optional[object] = None,
) -> Dict[str, Any]:
"""Return a standardized failed import payload for provider errors."""
safe_message = sanitize_connector_error(error if error is not None else message)
return {
**stats,
"success": False,
"error": safe_message,
"errors": [safe_message],
}
def load_ingested_ids(json_text: Optional[str]) -> List[str]:
"""Deserialize a connector ingested-message-id JSON column."""
if not json_text:
return []
try:
decoded = json.loads(json_text)
except (json.JSONDecodeError, TypeError):
return []
return [str(item) for item in decoded] if isinstance(decoded, list) else []
def dump_ingested_ids(ids: Iterable[Any]) -> str:
"""Serialize connector ingested-message IDs for database storage."""
return json.dumps([str(item) for item in ids])