campaign sending prototype

This commit is contained in:
2026-06-24 16:20:44 +02:00
parent 99fee44651
commit a9d16a2c89
12 changed files with 1478 additions and 41 deletions

View File

@@ -7,6 +7,11 @@ from govoplan_mail.backend.schemas import (
MailImapFolderListResponse,
MailImapFolderResponse,
MailImapTestRequest,
MailMailboxAttachmentResponse,
MailMailboxMessageDetailResponse,
MailMailboxMessageListResponse,
MailMailboxMessageResponse,
MailMailboxMessageSummaryResponse,
MailProfilePolicyResponse,
MailProfilePolicyUpdateRequest,
MailServerProfileCreateRequest,
@@ -30,7 +35,7 @@ from govoplan_mail.backend.mail_profiles import (
smtp_config_from_profile,
update_mail_server_profile,
)
from govoplan_mail.backend.sending.imap import list_imap_folders, test_imap_login
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
from govoplan_mail.backend.sending.smtp import test_smtp_login
from sqlalchemy.orm import Session
@@ -61,6 +66,11 @@ def _require_profile_credentials_scope(principal: ApiPrincipal, scope_type: str)
_require_scope(principal, "mail:secret:manage")
def _require_mailbox_read_scope(principal: ApiPrincipal) -> None:
_require_scope(principal, "mail:mailbox:read")
_require_scope(principal, "mail:profile:use")
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
if scope_type == "system":
_require_scope(principal, "system:settings:read")
@@ -83,6 +93,51 @@ def _profile_response(profile) -> MailServerProfileResponse:
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
return MailMailboxMessageSummaryResponse(
uid=message.uid,
folder=message.folder,
subject=message.subject,
from_header=message.from_header,
to_header=message.to_header,
cc_header=message.cc_header,
date=message.date,
message_id=message.message_id,
flags=message.flags,
size_bytes=message.size_bytes,
body_preview=message.body_preview,
attachment_count=message.attachment_count,
)
def _mailbox_detail_response(message) -> MailMailboxMessageDetailResponse:
return MailMailboxMessageDetailResponse(
uid=message.uid,
folder=message.folder,
subject=message.subject,
from_header=message.from_header,
to_header=message.to_header,
cc_header=message.cc_header,
date=message.date,
message_id=message.message_id,
flags=message.flags,
size_bytes=message.size_bytes,
body_preview=message.body_preview,
body_text=message.body_text,
body_html=message.body_html,
headers=message.headers,
attachments=[MailMailboxAttachmentResponse(filename=item.filename, content_type=item.content_type, size_bytes=item.size_bytes) for item in message.attachments],
)
def _imap_config_for_profile(session: Session, *, tenant_id: str, profile_id: str):
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=True)
imap = imap_config_from_profile(profile)
if imap is None:
raise MailProfileError("Mail-server profile has no IMAP configuration")
return imap
@router.get("/profiles", response_model=MailServerProfileListResponse)
def list_profiles(
include_inactive: bool = False,
@@ -311,6 +366,83 @@ def list_profile_imap_folders(
return MailImapFolderListResponse(ok=False, message=_safe_error_message(exc), folders=[], detected_sent_folder=None, details={"error_type": exc.__class__.__name__})
@router.get("/profiles/{profile_id}/mailbox/folders", response_model=MailImapFolderListResponse)
def list_profile_mailbox_folders(
profile_id: str,
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_folders(imap_config=imap)
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@router.get("/profiles/{profile_id}/mailbox/messages", response_model=MailMailboxMessageListResponse)
def list_profile_mailbox_messages(
profile_id: str,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
limit: int = Query(default=50, ge=1, le=100),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit)
return MailMailboxMessageListResponse(
profile_id=profile_id,
folder=result.folder,
host=result.host,
port=result.port,
security=result.security,
messages=[_mailbox_summary_response(message) for message in result.messages],
)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@router.get("/profiles/{profile_id}/mailbox/messages/{message_uid}", response_model=MailMailboxMessageResponse)
def get_profile_mailbox_message(
profile_id: str,
message_uid: str,
folder: str = Query(default="INBOX", min_length=1, max_length=255),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
_require_mailbox_read_scope(principal)
try:
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
result = get_imap_message(imap_config=imap, folder=folder, uid=message_uid)
return MailMailboxMessageResponse(
profile_id=profile_id,
folder=result.folder,
host=result.host,
port=result.port,
security=result.security,
message=_mailbox_detail_response(result.message),
)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except ImapConfigurationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
except ImapAppendError as exc:
status_code = status.HTTP_404_NOT_FOUND if "not found" in str(exc).casefold() else status.HTTP_502_BAD_GATEWAY
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
def _safe_error_message(exc: Exception) -> str:
text = str(exc).strip()
return text or exc.__class__.__name__