from __future__ import annotations import hashlib from collections import defaultdict from collections.abc import Mapping from datetime import datetime, timezone from email import policy from email.message import EmailMessage from email.parser import BytesParser from email.utils import parsedate_to_datetime from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_core.audit.logging import audit_event from govoplan_core.core.calendar import ( CalendarCapabilityError, calendar_invitation_provider, ) from govoplan_core.core.mail import ( MailBounceObservationRef, MailBounceProcessingProvider, ) from govoplan_mail.backend.db.models import ( MailBounceObservation, MailBounceSource, MailDeliveryCommand, MailServerProfile, ) from govoplan_mail.backend.sending.imap import ( get_imap_raw_message, list_imap_uids_since, ) from govoplan_mail.backend.server_hierarchy import ( MailServerHierarchyError, hierarchy_context_for_profile, resolve_mail_transport, ) from govoplan_mail.backend.runtime import get_registry from govoplan_mail.backend.recovery import ( MailRecoveryError, begin_bounce_scan_recovery, ) class MailBounceError(RuntimeError): pass def utcnow() -> datetime: return datetime.now(timezone.utc) def normalize_message_id(value: object | None) -> str | None: normalized = " ".join(str(value or "").split()) return normalized[:998] or None def _calendar_reply_parts(raw_message: bytes) -> tuple[tuple[str, str | None], ...]: try: message = BytesParser(policy=policy.default).parsebytes(raw_message) except Exception as exc: raise MailBounceError("Mail message could not be parsed.") from exc parts: list[tuple[str, str | None]] = [] for part in message.walk(): filename = part.get_filename() if part.get_content_type() != "text/calendar" and not str( filename or "" ).casefold().endswith(".ics"): continue try: content = part.get_content() except Exception: payload = part.get_payload(decode=True) or b"" content = payload.decode(part.get_content_charset() or "utf-8", errors="replace") if isinstance(content, bytes): content = content.decode( part.get_content_charset() or "utf-8", errors="replace", ) text = str(content).strip() if text: parts.append((text, filename)) return tuple(parts) def _reconcile_calendar_replies( session: Session, *, tenant_id: str, profile_id: str, folder: str, uid: str, raw_message: bytes, ) -> int: provider = calendar_invitation_provider(get_registry()) if provider is None: return 0 raw_sha256 = hashlib.sha256(raw_message).hexdigest() try: message = BytesParser(policy=policy.default).parsebytes(raw_message) message_id = normalize_message_id(message.get("Message-ID")) except Exception: message_id = None recorded_count = 0 for icalendar, filename in _calendar_reply_parts(raw_message): try: recorded = provider.record_icalendar_reply( session, tenant_id=tenant_id, icalendar=icalendar, received_at=utcnow(), evidence={ "transport": "mail-imap", "profile_id": profile_id, "folder": folder, "mailbox_uid": uid, "message_id": message_id, "filename": filename, "raw_sha256": raw_sha256, }, ) except CalendarCapabilityError: # Mailboxes routinely contain unrelated or malformed invitations. # They are not allowed to block DSN progress for the entire source. continue for invitation in recorded: audit_event( session, tenant_id=tenant_id, user_id=None, action="mail.calendar_reply.reconciled", object_type="calendar_invitation", object_id=invitation.event_id, details={ "profile_id": profile_id, "folder": folder, "mailbox_uid": uid, "message_id": message_id, "calendar_uid": invitation.uid, "correlation_id": invitation.correlation_id, "raw_sha256": raw_sha256, }, ) recorded_count += 1 return recorded_count def configure_bounce_source( session: Session, *, tenant_id: str, profile_id: str, folder: str = "INBOX", imap_server_id: str | None = None, imap_credential_id: str | None = None, is_active: bool = True, created_by_user_id: str | None = None, ) -> MailBounceSource: profile = _profile(session, tenant_id=tenant_id, profile_id=profile_id) clean_folder = folder.strip() or "INBOX" resolved = _resolve_imap( session, profile=profile, server_id=imap_server_id, credential_id=imap_credential_id, ) source = session.scalar( select(MailBounceSource).where( MailBounceSource.profile_id == profile.id, MailBounceSource.folder == clean_folder, ) ) if source is None: source = MailBounceSource( tenant_id=tenant_id, profile_id=profile.id, folder=clean_folder, expected_imap_transport_revision=resolved.transport_revision, created_by_user_id=created_by_user_id, ) session.add(source) source.imap_server_id = resolved.server.id if resolved.server else None source.imap_credential_id = ( resolved.credential.id if resolved.credential else None ) source.expected_imap_transport_revision = resolved.transport_revision source.is_active = is_active source.last_error = None session.flush() return source def list_bounce_sources( session: Session, *, tenant_id: str, ) -> tuple[MailBounceSource, ...]: return tuple( session.scalars( select(MailBounceSource) .where(MailBounceSource.tenant_id == tenant_id) .order_by(MailBounceSource.created_at, MailBounceSource.id) ) ) def delete_bounce_source( session: Session, *, tenant_id: str, source_id: str, ) -> None: source = session.get(MailBounceSource, source_id) if source is None or source.tenant_id != tenant_id: raise MailBounceError("Bounce source not found.") session.delete(source) session.flush() def list_bounce_observations( session: Session, *, tenant_id: str, command_id: str | None = None, limit: int = 100, ) -> tuple[MailBounceObservationRef, ...]: statement = select(MailBounceObservation).where( MailBounceObservation.tenant_id == tenant_id ) if command_id: statement = statement.where(MailBounceObservation.command_id == command_id) rows = session.scalars( statement.order_by( MailBounceObservation.observed_at.desc(), MailBounceObservation.id, ).limit(max(1, min(int(limit), 500))) ) return tuple(_observation_ref(item) for item in rows) class SqlMailBounceProcessingProvider(MailBounceProcessingProvider): def scan_source( self, session: object, *, tenant_id: str, source_id: str, limit: int = 100, ) -> Mapping[str, object]: db = _session(session) source = db.get(MailBounceSource, source_id) if source is None or source.tenant_id != tenant_id: raise MailBounceError("Bounce source not found.") processed, observations, calendar_replies = self._scan_source( db, source, limit=max(1, min(int(limit), 1_000)), ) return { "sources": 1, "processed_messages": processed, "observations": observations, "calendar_replies": calendar_replies, "failures": [], } def process_raw_message( self, session: object, *, tenant_id: str, profile_id: str, folder: str, uid: str, raw_message: bytes, reconcile_calendar: bool = True, ) -> tuple[MailBounceObservationRef, ...]: db = _session(session) if reconcile_calendar: _reconcile_calendar_replies( db, tenant_id=tenant_id, profile_id=profile_id, folder=folder, uid=uid, raw_message=raw_message, ) raw_sha256 = hashlib.sha256(raw_message).hexdigest() reports = parse_delivery_status(raw_message) observations: list[MailBounceObservationRef] = [] for report in reports: original_message_id = normalize_message_id( report.get("original_message_id") ) command = _correlated_command( db, tenant_id=tenant_id, original_message_id=original_message_id, command_id=str(report.get("command_id") or "") or None, ) fingerprint = _observation_fingerprint( tenant_id=tenant_id, profile_id=profile_id, folder=folder, uid=uid, raw_sha256=raw_sha256, recipient=report.get("recipient"), action=report.get("action"), status_code=report.get("status_code"), ) existing = db.scalar( select(MailBounceObservation).where( MailBounceObservation.tenant_id == tenant_id, MailBounceObservation.fingerprint == fingerprint, ) ) if existing is not None: observations.append(_observation_ref(existing)) continue action = str(report.get("action") or "unknown").casefold()[:40] status_code = _bounded(report.get("status_code"), 80) item = MailBounceObservation( tenant_id=tenant_id, profile_id=profile_id, folder=folder[:255], uid=uid[:255], fingerprint=fingerprint, raw_sha256=raw_sha256, original_message_id=original_message_id, command_id=command.id if command else None, recipient=_bounded(report.get("recipient"), 998), action=action, status_code=status_code, diagnostic=_bounded(report.get("diagnostic"), 500), permanent=action == "failed" or bool(status_code and status_code.startswith("5")), observed_at=report.get("observed_at") or utcnow(), matched=command is not None, evidence={ "reporting_mta": report.get("reporting_mta"), "remote_mta": report.get("remote_mta"), "diagnostic_type": report.get("diagnostic_type"), }, ) try: with db.begin_nested(): db.add(item) db.flush() except IntegrityError: item = db.scalar( select(MailBounceObservation).where( MailBounceObservation.tenant_id == tenant_id, MailBounceObservation.fingerprint == fingerprint, ) ) if item is None: raise audit_event( db, tenant_id=tenant_id, user_id=None, action="mail.bounce.observed", object_type="mail_bounce_observation", object_id=item.id, details={ "profile_id": profile_id, "folder": folder, "uid": uid, "command_id": item.command_id, "action": item.action, "status_code": item.status_code, "matched": item.matched, "raw_sha256": raw_sha256, }, ) observations.append(_observation_ref(item)) return tuple(observations) def scan_due( self, session: object, *, tenant_id: str | None = None, limit: int = 100, ) -> Mapping[str, object]: db = _session(session) remaining = max(1, min(int(limit), 1_000)) statement = select(MailBounceSource).where( MailBounceSource.is_active.is_(True) ) if tenant_id: statement = statement.where(MailBounceSource.tenant_id == tenant_id) sources = tuple( db.scalars(statement.order_by(MailBounceSource.last_scanned_at, MailBounceSource.id)) ) processed = 0 observations = 0 calendar_replies = 0 failures: list[dict[str, str]] = [] for source in sources: if remaining <= 0: break try: count, found, replies = self._scan_source( db, source, limit=remaining, ) processed += count observations += found calendar_replies += replies remaining -= count except Exception as exc: source.last_scanned_at = utcnow() source.last_error = _bounded(exc, 500) db.flush() failures.append({"source_id": source.id, "error": source.last_error or "Scan failed"}) return { "sources": len(sources), "processed_messages": processed, "observations": observations, "calendar_replies": calendar_replies, "failures": failures, } def observations_for_commands( self, session: object, *, tenant_id: str, command_ids: tuple[str, ...], ) -> Mapping[str, tuple[MailBounceObservationRef, ...]]: if not command_ids: return {} rows = _session(session).scalars( select(MailBounceObservation) .where( MailBounceObservation.tenant_id == tenant_id, MailBounceObservation.command_id.in_(tuple(set(command_ids))), ) .order_by(MailBounceObservation.observed_at, MailBounceObservation.id) ) grouped: defaultdict[str, list[MailBounceObservationRef]] = defaultdict(list) for row in rows: if row.command_id: grouped[row.command_id].append(_observation_ref(row)) return {key: tuple(value) for key, value in grouped.items()} def _scan_source( self, session: Session, source: MailBounceSource, *, limit: int, ) -> tuple[int, int, int]: profile = _profile( session, tenant_id=source.tenant_id, profile_id=source.profile_id, ) resolved = _resolve_imap( session, profile=profile, server_id=source.imap_server_id, credential_id=source.imap_credential_id, ) if resolved.transport_revision != source.expected_imap_transport_revision: raise MailBounceError( "Bounce-source IMAP settings changed; review and save the source before scanning." ) try: recovery = begin_bounce_scan_recovery( tenant_id=source.tenant_id, profile_id=source.profile_id, source_id=source.id, folder=source.folder, ) except MailRecoveryError as exc: raise MailBounceError(str(exc)) from exc try: page = list_imap_uids_since( imap_config=resolved.config, folder=source.folder, highest_uid=source.highest_processed_uid, expected_uidvalidity=source.uidvalidity, limit=limit, ) found = 0 calendar_replies = 0 highest = 0 if page.cursor_reset else source.highest_processed_uid for uid in page.uids: raw = get_imap_raw_message( imap_config=resolved.config, folder=source.folder, uid=uid, ) calendar_replies += _reconcile_calendar_replies( session, tenant_id=source.tenant_id, profile_id=source.profile_id, folder=source.folder, uid=uid, raw_message=raw.raw, ) found += len( self.process_raw_message( session, tenant_id=source.tenant_id, profile_id=source.profile_id, folder=source.folder, uid=uid, raw_message=raw.raw, reconcile_calendar=False, ) ) highest = max(highest, int(uid)) now = utcnow() source.uidvalidity = page.uidvalidity source.highest_processed_uid = highest source.last_scanned_at = now source.last_success_at = now source.last_error = None session.flush() session.commit() except Exception as exc: session.rollback() if not recovery.operation.closed: recovery.reject(code=exc.__class__.__name__) raise recovery.complete(highest_uid=highest, uidvalidity=page.uidvalidity) return len(page.uids), found, calendar_replies def parse_delivery_status(raw_message: bytes) -> tuple[Mapping[str, object], ...]: try: message = BytesParser(policy=policy.default).parsebytes(raw_message) except Exception as exc: raise MailBounceError("Bounce message could not be parsed.") from exc original_message_id = normalize_message_id(message.get("Original-Message-ID")) command_id = _bounded(message.get("X-GovOPlaN-Delivery-ID"), 36) reporting_mta = None reports: list[dict[str, object]] = [] is_report = message.get_content_type() == "multipart/report" for part in message.walk(): content_type = part.get_content_type() if content_type == "message/delivery-status": payload = part.get_payload() blocks = payload if isinstance(payload, list) else [] for position, block in enumerate(blocks): if not isinstance(block, EmailMessage): continue if position == 0: reporting_mta = block.get("Reporting-MTA") or reporting_mta original_message_id = normalize_message_id( block.get("Original-Message-ID") or original_message_id ) continue reports.append( _delivery_status_block( block, original_message_id=original_message_id, command_id=command_id, reporting_mta=reporting_mta, fallback_date=message.get("Date"), ) ) elif content_type == "message/rfc822": payload = part.get_payload() if isinstance(payload, list) and payload and isinstance(payload[0], EmailMessage): original_message_id = normalize_message_id( payload[0].get("Message-ID") or original_message_id ) command_id = _bounded( payload[0].get("X-GovOPlaN-Delivery-ID") or command_id, 36, ) elif content_type == "text/rfc822-headers": try: headers = BytesParser(policy=policy.default).parsebytes( part.get_payload(decode=True) or b"", headersonly=True, ) except Exception: continue original_message_id = normalize_message_id( headers.get("Message-ID") or original_message_id ) command_id = _bounded( headers.get("X-GovOPlaN-Delivery-ID") or command_id, 36, ) if reports: for report in reports: report["original_message_id"] = ( report.get("original_message_id") or original_message_id ) report["command_id"] = report.get("command_id") or command_id return tuple(reports) failed = message.get_all("X-Failed-Recipients", []) recipients = [value.strip() for header in failed for value in str(header).split(",") if value.strip()] if not recipients and not is_report: return () return tuple( { "original_message_id": original_message_id, "command_id": command_id, "recipient": recipient or None, "action": "failed" if recipient else "unknown", "status_code": None, "diagnostic": "Unstructured delivery-status report", "observed_at": _parsed_date(message.get("Date")) or utcnow(), "reporting_mta": reporting_mta, } for recipient in (recipients or [""]) ) def _delivery_status_block( block: EmailMessage, *, original_message_id: str | None, command_id: str | None, reporting_mta: object | None, fallback_date: object | None, ) -> dict[str, object]: diagnostic = str(block.get("Diagnostic-Code") or "") diagnostic_type, _, diagnostic_text = diagnostic.partition(";") return { "original_message_id": normalize_message_id( block.get("Original-Message-ID") or original_message_id ), "command_id": command_id, "recipient": _dsn_address( block.get("Final-Recipient") or block.get("Original-Recipient") ), "action": str(block.get("Action") or "unknown").casefold(), "status_code": _bounded(block.get("Status"), 80), "diagnostic": diagnostic_text.strip() or diagnostic_type.strip() or None, "diagnostic_type": diagnostic_type.strip() or None, "reporting_mta": str(reporting_mta or "") or None, "remote_mta": _dsn_address(block.get("Remote-MTA")), "observed_at": _parsed_date( block.get("Last-Attempt-Date") or fallback_date ) or utcnow(), } def _correlated_command( session: Session, *, tenant_id: str, original_message_id: str | None, command_id: str | None, ) -> MailDeliveryCommand | None: if command_id: command = session.get(MailDeliveryCommand, command_id) if command is not None and command.tenant_id == tenant_id: return command if not original_message_id: return None return session.scalar( select(MailDeliveryCommand).where( MailDeliveryCommand.tenant_id == tenant_id, MailDeliveryCommand.rfc_message_id == original_message_id, ) ) def _observation_fingerprint(**values: object) -> str: canonical = "\x1f".join(str(values[key] or "") for key in sorted(values)) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() def _observation_ref(item: MailBounceObservation) -> MailBounceObservationRef: return MailBounceObservationRef( id=item.id, tenant_id=item.tenant_id, profile_id=item.profile_id, folder=item.folder, uid=item.uid, original_message_id=item.original_message_id, command_id=item.command_id, recipient=item.recipient, action=item.action, status_code=item.status_code, diagnostic=item.diagnostic, permanent=item.permanent, observed_at=item.observed_at, matched=item.matched, evidence=dict(item.evidence or {}), ) def _profile(session: Session, *, tenant_id: str, profile_id: str) -> MailServerProfile: profile = session.get(MailServerProfile, profile_id) if profile is None or profile.tenant_id not in {None, tenant_id} or not profile.is_active: raise MailBounceError("Active Mail profile not found.") return profile def _resolve_imap( session: Session, *, profile: MailServerProfile, server_id: str | None, credential_id: str | None, ): try: resolved = resolve_mail_transport( session, profile=profile, protocol="imap", context=hierarchy_context_for_profile(profile, administrative=True), server_id=server_id, credential_id=credential_id, ) except MailServerHierarchyError as exc: raise MailBounceError(str(exc)) from exc return resolved def _session(value: object) -> Session: if not isinstance(value, Session): raise MailBounceError("Bounce processing requires a SQLAlchemy Session.") return value def _bounded(value: object | None, limit: int) -> str | None: text = " ".join(str(value or "").split()) return text[:limit] or None def _dsn_address(value: object | None) -> str | None: text = str(value or "") _, separator, address = text.partition(";") return _bounded(address if separator else text, 998) def _parsed_date(value: object | None) -> datetime | None: if not value: return None try: parsed = parsedate_to_datetime(str(value)) except (TypeError, ValueError, OverflowError): return None if parsed.tzinfo is None: return parsed.replace(tzinfo=timezone.utc) return parsed.astimezone(timezone.utc) __all__ = [ "MailBounceError", "SqlMailBounceProcessingProvider", "configure_bounce_source", "delete_bounce_source", "list_bounce_sources", "list_bounce_observations", "normalize_message_id", "parse_delivery_status", ]