165 lines
4.9 KiB
Python
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])
|