212 lines
7.6 KiB
Python
212 lines
7.6 KiB
Python
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",
|
|
]
|