from __future__ import annotations import hashlib from email import policy from email.message import Message from email.parser import BytesParser from email.utils import getaddresses from sqlalchemy.orm import Session from govoplan_core.core.mail import ( MailPostboxBridgeProvider, MailPostboxBridgeRequest, MailPostboxBridgeResult, ) from govoplan_core.core.modules import ModuleContext from govoplan_core.core.postbox import ( PostboxAttachmentRef, PostboxDeliveryRequest, PostboxParticipantRef, PostboxTargetRef, postbox_delivery_provider, ) from govoplan_mail.backend.db.models import MailServerProfile from govoplan_mail.backend.runtime import configure_runtime, get_registry MAX_BRIDGE_MESSAGE_BYTES = 50 * 1024 * 1024 MAX_POSTBOX_BODY_CHARS = 500_000 class MailPostboxBridgeError(RuntimeError): pass class MailPostboxBridge(MailPostboxBridgeProvider): """Translate immutable IMAP observations into native Postbox delivery.""" def bridge_message( self, session: object, request: MailPostboxBridgeRequest, ) -> MailPostboxBridgeResult: if not isinstance(session, Session): raise TypeError("Mail Postbox bridging requires a SQLAlchemy session.") if not isinstance(request.target, PostboxTargetRef): raise MailPostboxBridgeError("A typed Postbox target is required.") if len(request.raw_message) > MAX_BRIDGE_MESSAGE_BYTES: raise MailPostboxBridgeError("Mail message exceeds the Postbox bridge limit.") profile = session.get(MailServerProfile, request.profile_id) if profile is None or profile.tenant_id != request.tenant_id: raise MailPostboxBridgeError("Mail profile not found.") folder = request.folder.strip() uid = request.uid.strip() uidvalidity = request.uidvalidity.strip() if not folder or not uid or not uidvalidity: raise MailPostboxBridgeError( "Mail folder, UIDVALIDITY, and immutable UID are required." ) try: message = BytesParser(policy=policy.default).parsebytes(request.raw_message) except Exception as exc: raise MailPostboxBridgeError("Mail message could not be parsed.") from exc provider = postbox_delivery_provider(get_registry()) if provider is None: raise MailPostboxBridgeError("Postbox delivery is not available.") source_digest = hashlib.sha256(request.raw_message).hexdigest() source_key = hashlib.sha256( f"{request.tenant_id}\0{request.profile_id}\0{folder}\0{uidvalidity}\0{uid}".encode("utf-8") ).hexdigest() result = provider.deliver( session, PostboxDeliveryRequest( tenant_id=request.tenant_id, target=request.target, producer_module="mail", producer_resource_type="imap_message", producer_resource_id=source_key, idempotency_key=f"mail-postbox:{source_key}:{source_digest}", subject=_header(message, "Subject") or "(No subject)", body_text=_plain_text_body(message), sender_label=_header(message, "From"), classification=request.classification, participants=_participants(message), attachments=_attachments( message, profile_id=request.profile_id, folder=folder, uidvalidity=uidvalidity, uid=uid, ), metadata={ **dict(request.metadata), "transport": "mail-imap", "mail_profile_id": request.profile_id, "mailbox_folder": folder, "mailbox_uidvalidity": uidvalidity, "mailbox_uid": uid, "rfc_message_id": _header(message, "Message-ID"), "raw_sha256": source_digest, }, ), ) return MailPostboxBridgeResult( postbox_id=result.postbox_id, message_id=result.message_id, delivery_id=result.delivery_id, duplicate=result.duplicate, source_digest=source_digest, ) def _header(message: Message, name: str) -> str | None: value = " ".join(str(message.get(name) or "").split()) return value[:1000] or None def _plain_text_body(message: Message) -> str | None: candidates = message.walk() if message.is_multipart() else (message,) for part in candidates: if part.get_content_type() != "text/plain": continue if part.get_content_disposition() == "attachment": continue try: value = part.get_content() except Exception: payload = part.get_payload(decode=True) or b"" value = payload.decode(part.get_content_charset() or "utf-8", errors="replace") if isinstance(value, bytes): value = value.decode(part.get_content_charset() or "utf-8", errors="replace") normalized = str(value).strip() if normalized: return normalized[:MAX_POSTBOX_BODY_CHARS] return None def _participants(message: Message) -> tuple[PostboxParticipantRef, ...]: result: list[PostboxParticipantRef] = [] for kind, headers in ( ("sender", ("From",)), ("to", ("To",)), ("cc", ("Cc",)), ("bcc", ("Bcc",)), ): for name, address in getaddresses( [str(value) for header in headers for value in message.get_all(header, [])] ): clean_address = address.strip()[:500] if not clean_address: continue result.append( PostboxParticipantRef( kind=kind, reference_type="external_email", label=name.strip()[:500] or None, address=clean_address, ) ) return tuple(result) def _attachments( message: Message, *, profile_id: str, folder: str, uidvalidity: str, uid: str, ) -> tuple[PostboxAttachmentRef, ...]: result: list[PostboxAttachmentRef] = [] for index, part in enumerate(message.walk()): filename = part.get_filename() if part.get_content_disposition() != "attachment" and not filename: continue payload = part.get_payload(decode=True) or b"" digest = hashlib.sha256(payload).hexdigest() reference_id = hashlib.sha256( f"{profile_id}\0{folder}\0{uidvalidity}\0{uid}\0{index}\0{digest}".encode("utf-8") ).hexdigest() result.append( PostboxAttachmentRef( reference_type="mail_attachment", reference_id=reference_id, name=str(filename or f"attachment-{index + 1}")[:1000], media_type=part.get_content_type(), size_bytes=len(payload), digest=digest, metadata={ "mail_profile_id": profile_id, "mailbox_folder": folder, "mailbox_uidvalidity": uidvalidity, "mailbox_uid": uid, "mime_part_index": index, }, ) ) return tuple(result) def create_postbox_bridge(context: ModuleContext) -> MailPostboxBridge: configure_runtime(registry=context.registry, settings=context.settings) return MailPostboxBridge() __all__ = [ "MailPostboxBridge", "MailPostboxBridgeError", "create_postbox_bridge", ]