chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:22 +02:00
parent b4b0c76455
commit d5d5b89603
21 changed files with 1955 additions and 551 deletions

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_mail.backend.schemas import (
MailConnectionTestResponse,
@@ -14,13 +16,23 @@ from govoplan_mail.backend.schemas import (
MailMailboxMessageSummaryResponse,
MailProfilePolicyResponse,
MailProfilePolicyUpdateRequest,
MailSettingsDeltaResponse,
MailServerProfileCreateRequest,
MailServerProfileListResponse,
MailServerProfileResponse,
MailServerProfileUpdateRequest,
MailSmtpTestRequest,
)
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope
from govoplan_access.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
decode_sequence_watermark,
encode_sequence_watermark,
record_change,
sequence_watermark_is_expired,
)
from govoplan_core.core.pagination import KeysetCursorError, decode_keyset_cursor, encode_keyset_cursor, keyset_query_fingerprint
from govoplan_core.db.session import get_session
from govoplan_mail.backend.mail_profiles import (
MailProfileError,
@@ -39,10 +51,18 @@ from govoplan_mail.backend.mail_profiles import (
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
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
router = APIRouter(prefix="/mail", tags=["mail"])
MAIL_MODULE_ID = "mail"
MAIL_PROFILES_COLLECTION = "mail.profiles"
MAIL_POLICIES_COLLECTION = "mail.profile_policies"
MAIL_SETTINGS_COLLECTIONS = (MAIL_PROFILES_COLLECTION, MAIL_POLICIES_COLLECTION)
MAIL_PROFILE_RESOURCE = "mail_profile"
MAIL_POLICY_RESOURCE = "mail_profile_policy"
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
@@ -95,6 +115,122 @@ def _profile_response(profile) -> MailServerProfileResponse:
return MailServerProfileResponse.model_validate(profile_response_payload(profile))
def _policy_response(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
campaign_id: str | None = None,
) -> MailProfilePolicyResponse:
policy = get_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id)
effective_policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=tenant_id,
scope_type=scope_type,
scope_id=scope_id,
campaign_id=campaign_id,
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(
scope_type=scope_type,
scope_id=scope_id,
policy=policy,
effective_policy=effective,
parent_policy=parent,
effective_policy_sources=effective_sources,
parent_policy_sources=parent_sources,
)
def _record_mail_change(
session: Session,
*,
collection: str,
resource_type: str,
resource_id: str | None,
operation: str,
principal: ApiPrincipal,
tenant_id: str | None,
payload: dict[str, object] | None = None,
) -> None:
if not resource_id:
return
record_change(
session,
module_id=MAIL_MODULE_ID,
collection=collection,
resource_type=resource_type,
resource_id=resource_id,
operation=operation,
tenant_id=tenant_id,
actor_type="user",
actor_id=principal.user.id,
payload=payload or {},
)
def _mail_delta_query(session: Session, *, tenant_id: str, since_sequence: int):
return session.query(ChangeSequenceEntry).filter(
ChangeSequenceEntry.id > since_sequence,
ChangeSequenceEntry.module_id == MAIL_MODULE_ID,
ChangeSequenceEntry.collection.in_(MAIL_SETTINGS_COLLECTIONS),
or_(ChangeSequenceEntry.tenant_id == tenant_id, ChangeSequenceEntry.tenant_id.is_(None)),
)
def _mail_settings_watermark(session: Session, *, tenant_id: str) -> str:
sequence_id = _mail_delta_query(session, tenant_id=tenant_id, since_sequence=0).with_entities(func.max(ChangeSequenceEntry.id)).scalar()
return encode_sequence_watermark(int(sequence_id or 0))
def _mail_settings_entries(session: Session, *, tenant_id: str, since: str, limit: int):
try:
since_sequence = decode_sequence_watermark(since)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
expired_for_tenant = sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=tenant_id,
module_id=MAIL_MODULE_ID,
collections=MAIL_SETTINGS_COLLECTIONS,
)
expired_for_system = sequence_watermark_is_expired(
session,
since=since_sequence,
tenant_id=None,
module_id=MAIL_MODULE_ID,
collections=MAIL_SETTINGS_COLLECTIONS,
)
if expired_for_tenant or expired_for_system:
return None, False
entries_plus_one = _mail_delta_query(
session,
tenant_id=tenant_id,
since_sequence=since_sequence,
).order_by(ChangeSequenceEntry.id.asc()).limit(limit + 1).all()
has_more = len(entries_plus_one) > limit
return entries_plus_one[:limit], has_more
def _mail_settings_response_watermark(session: Session, *, tenant_id: str, entries, has_more: bool) -> str:
return encode_sequence_watermark(entries[-1].id) if has_more and entries else _mail_settings_watermark(session, tenant_id=tenant_id)
def _mail_deleted_item(entry) -> DeltaDeletedItem:
return DeltaDeletedItem(
id=entry.resource_id,
resource_type=entry.resource_type,
revision=encode_sequence_watermark(entry.id),
deleted_at=entry.created_at if entry.operation == "deleted" else None,
)
def _mailbox_summary_response(message) -> MailMailboxMessageSummaryResponse:
return MailMailboxMessageSummaryResponse(
uid=message.uid,
@@ -132,6 +268,76 @@ def _mailbox_detail_response(message) -> MailMailboxMessageDetailResponse:
)
def _mailbox_cursor_fingerprint(*, tenant_id: str, profile_id: str, folder: str, limit: int) -> str:
return keyset_query_fingerprint(
MAILBOX_MESSAGES_CURSOR_SCOPE,
{"tenant_id": tenant_id, "profile_id": profile_id, "folder": folder, "limit": limit, "sort": "imap.uid.desc"},
)
def _mailbox_cursor_values(cursor: str | None, *, fingerprint: str | None = None) -> dict[str, object] | None:
try:
return decode_keyset_cursor(MAILBOX_MESSAGES_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
except KeysetCursorError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
def _mailbox_cursor_limit(cursor: str | None, explicit_limit: int | None) -> int:
if explicit_limit is not None:
return explicit_limit
values = _mailbox_cursor_values(cursor) if cursor else None
raw_limit = values.get("limit") if values else DEFAULT_MAILBOX_MESSAGE_LIMIT
if not isinstance(raw_limit, int) or raw_limit < 1 or raw_limit > 100:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
return raw_limit
def _mailbox_cursor_position(cursor: str | None, *, fingerprint: str, offset: int) -> tuple[int, str | None, str | None, dict[str, object] | None]:
values = _mailbox_cursor_values(cursor, fingerprint=fingerprint) if cursor else None
if values is None:
return offset, None, None, None
raw_offset = values.get("offset")
if not isinstance(raw_offset, int) or raw_offset < 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
after_uid = values.get("after_uid") or values.get("anchor_uid")
uidvalidity = values.get("uidvalidity")
if not isinstance(after_uid, str) or not after_uid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
if uidvalidity is not None and not isinstance(uidvalidity, str):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid pagination cursor")
return raw_offset, after_uid, uidvalidity, values
def _next_mailbox_cursor(
*,
tenant_id: str,
profile_id: str,
folder: str,
limit: int,
offset: int,
total_count: int,
uidvalidity: str | None,
messages,
) -> tuple[str | None, bool]:
cursor_stable = bool(uidvalidity)
next_offset = offset + len(messages)
if not cursor_stable or next_offset >= total_count or not messages:
return None, cursor_stable
return (
encode_keyset_cursor(
MAILBOX_MESSAGES_CURSOR_SCOPE,
fingerprint=_mailbox_cursor_fingerprint(tenant_id=tenant_id, profile_id=profile_id, folder=folder, limit=limit),
values={
"offset": next_offset,
"limit": limit,
"uidvalidity": uidvalidity,
"after_uid": messages[-1].uid,
},
),
cursor_stable,
)
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)
@@ -146,6 +352,104 @@ def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, sco
return parent_mail_profile_policy(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id).as_dict()
def _full_mail_settings_delta_response(
session: Session,
*,
tenant_id: str,
scope_type: str,
scope_id: str | None,
include_inactive: bool,
campaign_id: str | None,
) -> MailSettingsDeltaResponse:
profiles = list_mail_server_profiles(
session,
tenant_id=tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
return MailSettingsDeltaResponse(
profiles=[_profile_response(profile) for profile in profiles],
policy=_policy_response(session, tenant_id=tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id),
changed_sections=["profiles", "policy"],
deleted=[],
watermark=_mail_settings_watermark(session, tenant_id=tenant_id),
has_more=False,
full=True,
)
@router.get("/settings/delta", response_model=MailSettingsDeltaResponse)
def mail_settings_delta(
scope_type: str = Query(default="tenant"),
scope_id: str | None = Query(default=None),
include_inactive: bool = False,
campaign_id: str | None = Query(default=None),
since: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
principal: ApiPrincipal = Depends(get_api_principal),
session: Session = Depends(get_session),
):
clean_scope_type = scope_type.strip().casefold()
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
_require_policy_read_scope(principal, clean_scope_type)
if since is None:
return _full_mail_settings_delta_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope_type,
scope_id=scope_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
entries, has_more = _mail_settings_entries(session, tenant_id=principal.tenant_id, since=since, limit=limit)
if entries is None:
return _full_mail_settings_delta_response(
session,
tenant_id=principal.tenant_id,
scope_type=clean_scope_type,
scope_id=scope_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
changed_profile_ids = {
entry.resource_id
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION and entry.resource_type == MAIL_PROFILE_RESOURCE
}
visible_profiles = {
profile.id: profile
for profile in list_mail_server_profiles(
session,
tenant_id=principal.tenant_id,
include_inactive=include_inactive,
campaign_id=campaign_id,
)
if profile.id in changed_profile_ids
}
policy_changed = any(entry.collection == MAIL_POLICIES_COLLECTION for entry in entries)
changed_sections = []
if changed_profile_ids:
changed_sections.append("profiles")
if policy_changed:
changed_sections.append("policy")
deleted = [
_mail_deleted_item(entry)
for entry in entries
if entry.collection == MAIL_PROFILES_COLLECTION
and entry.resource_type == MAIL_PROFILE_RESOURCE
and entry.resource_id not in visible_profiles
]
return MailSettingsDeltaResponse(
profiles=[_profile_response(profile) for profile in visible_profiles.values()],
policy=_policy_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope_type, scope_id=scope_id, campaign_id=campaign_id) if policy_changed else None,
changed_sections=changed_sections,
deleted=deleted,
watermark=_mail_settings_response_watermark(session, tenant_id=principal.tenant_id, entries=entries, has_more=has_more),
has_more=has_more,
full=False,
)
@router.get("/profiles", response_model=MailServerProfileListResponse)
def list_profiles(
include_inactive: bool = False,
@@ -189,12 +493,22 @@ def create_profile(
scope_type=payload.scope_type,
scope_id=payload.scope_id,
)
_record_mail_change(
session,
collection=MAIL_PROFILES_COLLECTION,
resource_type=MAIL_PROFILE_RESOURCE,
resource_id=profile.id,
operation="created",
principal=principal,
tenant_id=profile.tenant_id or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse)
@@ -245,12 +559,22 @@ def update_profile(
imap=imap_config,
clear_imap=payload.clear_imap,
)
_record_mail_change(
session,
collection=MAIL_PROFILES_COLLECTION,
resource_type=MAIL_PROFILE_RESOURCE,
resource_id=profile.id,
operation="updated",
principal=principal,
tenant_id=profile.tenant_id or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
@@ -264,6 +588,16 @@ def deactivate_profile(
_require_profile_write_scope(principal, profile.scope_type or "tenant")
profile.is_active = False
session.add(profile)
_record_mail_change(
session,
collection=MAIL_PROFILES_COLLECTION,
resource_type=MAIL_PROFILE_RESOURCE,
resource_id=profile.id,
operation="deleted",
principal=principal,
tenant_id=profile.tenant_id or principal.tenant_id,
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
)
session.commit()
session.refresh(profile)
return _profile_response(profile)
@@ -283,20 +617,7 @@ def read_mail_profile_policy(
scope_type = scope_type.strip().casefold()
_require_policy_read_scope(principal, scope_type)
try:
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
effective_policy = effective_mail_profile_policy_for_scope(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
campaign_id=campaign_id,
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id)
except MailProfileError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -319,22 +640,21 @@ def write_mail_profile_policy(
scope_id=scope_id,
policy=payload.policy.model_dump(mode="json"),
)
session.commit()
effective_policy = effective_mail_profile_policy_for_scope(
_record_mail_change(
session,
tenant_id=principal.tenant_id,
scope_type=scope_type,
scope_id=scope_id,
collection=MAIL_POLICIES_COLLECTION,
resource_type=MAIL_POLICY_RESOURCE,
resource_id=f"{scope_type}:{scope_id or ''}",
operation="updated",
principal=principal,
tenant_id=None if scope_type == "system" else principal.tenant_id,
payload={"scope_type": scope_type, "scope_id": scope_id},
)
effective = effective_policy.as_dict()
effective_sources = effective_policy.source_policies
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
parent = parent_policy.as_dict() if parent_policy else None
parent_sources = parent_policy.source_policies if parent_policy else []
return MailProfilePolicyResponse(scope_type=scope_type, scope_id=scope_id, policy=policy, effective_policy=effective, parent_policy=parent, effective_policy_sources=effective_sources, parent_policy_sources=parent_sources)
session.commit()
return _policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
except MailProfileError as exc:
session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse)
@@ -414,7 +734,7 @@ def list_profile_mailbox_folders(
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
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -423,15 +743,37 @@ def list_profile_mailbox_folders(
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),
limit: int | None = Query(default=None, ge=1, le=100),
offset: int = Query(default=0, ge=0, le=100000),
cursor: str | None = None,
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, offset=offset)
effective_limit = _mailbox_cursor_limit(cursor, limit)
fingerprint = _mailbox_cursor_fingerprint(tenant_id=principal.tenant_id, profile_id=profile_id, folder=folder, limit=effective_limit)
effective_offset, after_uid, expected_uidvalidity, cursor_values = _mailbox_cursor_position(cursor, fingerprint=fingerprint, offset=offset)
result = list_imap_messages(
imap_config=imap,
folder=folder,
limit=effective_limit,
offset=effective_offset,
after_uid=after_uid,
expected_uidvalidity=expected_uidvalidity,
)
full = bool(cursor_values is not None and result.cursor_reset)
next_cursor, cursor_stable = _next_mailbox_cursor(
tenant_id=principal.tenant_id,
profile_id=profile_id,
folder=result.folder,
limit=result.limit,
offset=result.offset,
total_count=result.total_count,
uidvalidity=result.uidvalidity,
messages=result.messages,
)
return MailMailboxMessageListResponse(
profile_id=profile_id,
folder=result.folder,
@@ -441,12 +783,16 @@ def list_profile_mailbox_messages(
total_count=result.total_count,
offset=result.offset,
limit=result.limit,
cursor=cursor,
next_cursor=next_cursor,
cursor_stable=cursor_stable,
full=full or not cursor_stable,
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
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except ImapAppendError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
@@ -474,7 +820,7 @@ def get_profile_mailbox_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
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, 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