Add bounce mailbox processing

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent 2721ee6542
commit fe309dbff5
13 changed files with 1770 additions and 7 deletions
+153
View File
@@ -12,6 +12,12 @@ from govoplan_mail.backend.schemas import (
MailAddressLookupCandidate,
MailAddressLookupResponse,
MailConnectionTestResponse,
MailBounceObservationListResponse,
MailBounceObservationResponse,
MailBounceScanResponse,
MailBounceSourceListResponse,
MailBounceSourceRequest,
MailBounceSourceResponse,
MailCampaignCredentialCreateRequest,
MailCredentialBindRequest,
MailCredentialCreateRequest,
@@ -43,6 +49,7 @@ from govoplan_mail.backend.schemas import (
MailSmtpTestRequest,
)
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.audit.logging import audit_event
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.change_sequence import (
ChangeSequenceEntry,
@@ -90,6 +97,14 @@ from govoplan_mail.backend.delivery_outbox import (
reconcile_delivery_command,
resend_delivery_command,
)
from govoplan_mail.backend.bounce_processing import (
MailBounceError,
SqlMailBounceProcessingProvider,
configure_bounce_source,
delete_bounce_source,
list_bounce_observations,
list_bounce_sources,
)
from govoplan_mail.backend.server_hierarchy import (
MailHierarchyContext,
MailServerHierarchyError,
@@ -129,6 +144,7 @@ MAIL_CREDENTIAL_RESOURCE = "mail_credential"
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
bounce_provider = SqlMailBounceProcessingProvider()
@router.get(
@@ -239,6 +255,143 @@ def resend_mail_delivery_command(
return MailDeliveryCommandResponse(result=result)
@router.get("/bounce-sources", response_model=MailBounceSourceListResponse)
def get_mail_bounce_sources(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MailBounceSourceListResponse:
_require_scope(principal, "mail:bounce:read")
return MailBounceSourceListResponse(
sources=[
MailBounceSourceResponse.model_validate(item, from_attributes=True)
for item in list_bounce_sources(
session,
tenant_id=principal.tenant_id,
)
]
)
@router.post("/bounce-sources", response_model=MailBounceSourceResponse)
def save_mail_bounce_source(
payload: MailBounceSourceRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MailBounceSourceResponse:
_require_scope(principal, "mail:bounce:manage")
try:
source = configure_bounce_source(
session,
tenant_id=principal.tenant_id,
profile_id=payload.profile_id,
folder=payload.folder,
imap_server_id=payload.imap_server_id,
imap_credential_id=payload.imap_credential_id,
is_active=payload.is_active,
created_by_user_id=principal.user.id,
)
except MailBounceError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
action="mail.bounce_source.saved",
object_type="mail_bounce_source",
object_id=source.id,
details={
"profile_id": source.profile_id,
"folder": source.folder,
"is_active": source.is_active,
},
)
session.commit()
return MailBounceSourceResponse.model_validate(source, from_attributes=True)
@router.delete("/bounce-sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_mail_bounce_source(
source_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> None:
_require_scope(principal, "mail:bounce:manage")
try:
delete_bounce_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
)
except MailBounceError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
action="mail.bounce_source.deleted",
object_type="mail_bounce_source",
object_id=source_id,
details={},
)
session.commit()
@router.post(
"/bounce-sources/{source_id}/scan",
response_model=MailBounceScanResponse,
)
def scan_mail_bounce_source(
source_id: str,
limit: int = Query(default=100, ge=1, le=1_000),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MailBounceScanResponse:
_require_scope(principal, "mail:bounce:manage")
try:
result = bounce_provider.scan_source(
session,
tenant_id=principal.tenant_id,
source_id=source_id,
limit=limit,
)
except MailBounceError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
session.commit()
return MailBounceScanResponse.model_validate(result)
@router.get(
"/bounce-observations",
response_model=MailBounceObservationListResponse,
)
def get_mail_bounce_observations(
command_id: str | None = Query(default=None, max_length=36),
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> MailBounceObservationListResponse:
_require_scope(principal, "mail:bounce:read")
return MailBounceObservationListResponse(
observations=[
MailBounceObservationResponse.model_validate(
dataclasses.asdict(item)
)
for item in list_bounce_observations(
session,
tenant_id=principal.tenant_id,
command_id=command_id,
limit=limit,
)
]
)
def _capability_payload(value: object) -> dict[str, Any]:
if dataclasses.is_dataclass(value):
return dataclasses.asdict(value)