2741 lines
97 KiB
Python
2741 lines
97 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy import func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_mail.backend.schemas import (
|
|
MailAddressLookupCandidate,
|
|
MailAddressLookupResponse,
|
|
MailConnectionTestResponse,
|
|
MailCampaignCredentialCreateRequest,
|
|
MailCredentialBindRequest,
|
|
MailCredentialCreateRequest,
|
|
MailCredentialEnvelopeResponse,
|
|
MailCredentialListResponse,
|
|
MailCredentialUpdateRequest,
|
|
MailImapFolderListResponse,
|
|
MailImapFolderResponse,
|
|
MailMailboxBootstrapResponse,
|
|
MailImapTestRequest,
|
|
MailMailboxAttachmentResponse,
|
|
MailMailboxMessageDetailResponse,
|
|
MailMailboxMessageListResponse,
|
|
MailMailboxMessageResponse,
|
|
MailMailboxMessageSummaryResponse,
|
|
MailProfilePolicyResponse,
|
|
MailProfilePolicyUpdateRequest,
|
|
MailSettingsDeltaResponse,
|
|
MailServerProfileCreateRequest,
|
|
MailServerEndpointCreateRequest,
|
|
MailServerEndpointResponse,
|
|
MailServerEndpointUpdateRequest,
|
|
MailServerProfileListResponse,
|
|
MailServerProfileResponse,
|
|
MailServerProfileUpdateRequest,
|
|
MailSmtpTestRequest,
|
|
)
|
|
from govoplan_core.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.mailbox_index import (
|
|
cache_mailbox_folders,
|
|
cache_mailbox_messages,
|
|
cached_mailbox_folders,
|
|
cached_mailbox_message_page,
|
|
clear_mailbox_index,
|
|
)
|
|
from govoplan_mail.backend.mail_profiles import (
|
|
MailProfileError,
|
|
campaign_mail_owner_context,
|
|
campaign_mail_context_visible_to_actor,
|
|
create_mail_server_profile,
|
|
delete_mail_profile_credentials,
|
|
effective_mail_profile_policy_for_scope,
|
|
get_mail_profile_policy,
|
|
get_mail_server_profile,
|
|
get_mail_server_profile_for_actor,
|
|
imap_config_from_profile,
|
|
list_mail_server_profiles,
|
|
mail_profile_visible_to_actor,
|
|
mail_profile_scope_visible_to_actor,
|
|
parent_mail_profile_policy,
|
|
profile_response_payload,
|
|
set_mail_profile_policy,
|
|
smtp_config_from_profile,
|
|
update_mail_server_profile,
|
|
)
|
|
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
|
from govoplan_mail.backend.runtime import get_registry
|
|
from govoplan_mail.backend.server_hierarchy import (
|
|
MailHierarchyContext,
|
|
MailServerHierarchyError,
|
|
bind_mail_credential,
|
|
create_and_bind_mail_credential,
|
|
create_mail_server_endpoint,
|
|
get_available_mail_credential,
|
|
get_bound_mail_credential,
|
|
get_mail_server_endpoint,
|
|
hierarchy_context_for_profile,
|
|
initialize_profile_hierarchy,
|
|
list_available_mail_credentials,
|
|
mail_credential_payload,
|
|
mail_server_ref,
|
|
mail_server_endpoint_payload,
|
|
profile_hierarchy_payload,
|
|
resolve_mail_transport,
|
|
sync_default_profile_credential,
|
|
sync_default_profile_server,
|
|
unlink_mail_credential,
|
|
update_bound_mail_credential,
|
|
update_mail_server_endpoint,
|
|
)
|
|
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, load_imap_mailbox_bootstrap, test_imap_login
|
|
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
|
|
|
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"
|
|
MAIL_SERVER_RESOURCE = "mail_server"
|
|
MAIL_CREDENTIAL_RESOURCE = "mail_credential"
|
|
MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1"
|
|
DEFAULT_MAILBOX_MESSAGE_LIMIT = 50
|
|
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
|
|
|
|
|
def _capability_payload(value: object) -> dict[str, Any]:
|
|
if dataclasses.is_dataclass(value):
|
|
return dataclasses.asdict(value)
|
|
if isinstance(value, dict):
|
|
return dict(value)
|
|
payload: dict[str, Any] = {}
|
|
for key in (
|
|
"contact_id",
|
|
"address_book_id",
|
|
"display_name",
|
|
"email",
|
|
"email_label",
|
|
"organization",
|
|
"role_title",
|
|
"tags",
|
|
"source_kind",
|
|
"source_ref",
|
|
"source_revision",
|
|
"provenance",
|
|
):
|
|
if hasattr(value, key):
|
|
payload[key] = getattr(value, key)
|
|
return payload
|
|
|
|
|
|
def _registry_capability(name: str) -> object | None:
|
|
registry = get_registry()
|
|
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name):
|
|
return None
|
|
return registry.capability(name)
|
|
|
|
|
|
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
|
if not has_scope(principal, scope):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}")
|
|
|
|
|
|
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
|
if not any(has_scope(principal, scope) for scope in scopes):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
|
|
|
|
|
|
def _is_own_user_scope(principal: ApiPrincipal, scope_type: str, scope_id: str | None) -> bool:
|
|
return scope_type == "user" and scope_id == principal.user.id
|
|
|
|
|
|
def _require_profile_write_scope(
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None = None,
|
|
) -> None:
|
|
if scope_type == "system":
|
|
_require_scope(principal, "system:settings:write")
|
|
return
|
|
if has_scope(principal, "mail:profile:write"):
|
|
return
|
|
if _is_own_user_scope(principal, scope_type, scope_id) and has_scope(
|
|
principal, "mail:profile:write_own"
|
|
):
|
|
return
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Missing scope: mail:profile:write",
|
|
)
|
|
|
|
|
|
def _require_profile_credentials_scope(
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None = None,
|
|
) -> None:
|
|
if scope_type == "system":
|
|
_require_scope(principal, "system:settings:write")
|
|
return
|
|
if has_scope(principal, "mail:secret:manage"):
|
|
return
|
|
if _is_own_user_scope(principal, scope_type, scope_id) and has_scope(
|
|
principal, "mail:secret:manage_own"
|
|
):
|
|
return
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Missing scope: mail:secret:manage",
|
|
)
|
|
|
|
|
|
def _profile_for_mutation(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
profile_id: str,
|
|
):
|
|
"""Resolve a profile through the actor's mutation boundary.
|
|
|
|
Mutation routes deliberately do not apply effective transport policy here:
|
|
an owner must still be able to repair or deactivate a profile after policy
|
|
becomes more restrictive. Missing and non-owned identifiers are kept
|
|
indistinguishable for self-service actors.
|
|
"""
|
|
|
|
try:
|
|
profile = get_mail_server_profile(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
profile_id=profile_id,
|
|
for_update=True,
|
|
mutation_owner_user_id=(
|
|
principal.user.id
|
|
if has_scope(principal, "mail:profile:write_own")
|
|
else None
|
|
),
|
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
|
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
|
)
|
|
_require_profile_write_scope(
|
|
principal,
|
|
profile.scope_type or "tenant",
|
|
profile.scope_id,
|
|
)
|
|
except (MailProfileError, HTTPException) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Mail-server profile not found",
|
|
) from exc
|
|
return profile
|
|
|
|
|
|
def _server_for_mutation(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
profile_id: str,
|
|
server_id: str,
|
|
):
|
|
profile = _profile_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
)
|
|
try:
|
|
server = get_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
server_id=server_id,
|
|
context=hierarchy_context_for_profile(
|
|
profile,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
administrative=True,
|
|
),
|
|
require_active=False,
|
|
for_update=True,
|
|
)
|
|
except MailServerHierarchyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Mail server not found",
|
|
) from exc
|
|
return profile, server
|
|
|
|
|
|
def _server_response(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
profile,
|
|
server,
|
|
) -> MailServerEndpointResponse:
|
|
context = hierarchy_context_for_profile(
|
|
profile,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
administrative=True,
|
|
)
|
|
return MailServerEndpointResponse.model_validate(
|
|
mail_server_endpoint_payload(
|
|
session,
|
|
server=server,
|
|
context=context,
|
|
include_inactive_credentials=True,
|
|
)
|
|
)
|
|
|
|
|
|
def _record_profile_child_change(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
profile,
|
|
operation: str,
|
|
resource_type: str,
|
|
resource_id: str,
|
|
) -> None:
|
|
_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,
|
|
payload={
|
|
"scope_type": profile.scope_type,
|
|
"scope_id": profile.scope_id,
|
|
"child_operation": operation,
|
|
"child_resource_type": resource_type,
|
|
"child_resource_id": resource_id,
|
|
},
|
|
)
|
|
|
|
|
|
_TRANSPORT_ENDPOINT_FIELDS = ("host", "port", "security")
|
|
|
|
|
|
def _transport_endpoint_changed(
|
|
current: dict[str, Any] | None,
|
|
updated: SmtpConfig | ImapConfig,
|
|
) -> bool:
|
|
current_payload = current or {}
|
|
updated_payload = updated.model_dump(mode="json")
|
|
return any(
|
|
current_payload.get(field) != updated_payload.get(field)
|
|
for field in _TRANSPORT_ENDPOINT_FIELDS
|
|
)
|
|
|
|
|
|
def _profile_update_requires_credentials_scope(
|
|
profile,
|
|
*,
|
|
payload: MailServerProfileUpdateRequest,
|
|
smtp: SmtpConfig | None,
|
|
imap: ImapConfig | None,
|
|
) -> bool:
|
|
if payload.credentials_supplied() or payload.clear_imap:
|
|
return True
|
|
if (
|
|
smtp is not None
|
|
and getattr(profile, "smtp_password_encrypted", None)
|
|
and _transport_endpoint_changed(profile.smtp_config, smtp)
|
|
):
|
|
return True
|
|
return bool(
|
|
imap is not None
|
|
and getattr(profile, "imap_password_encrypted", None)
|
|
and _transport_endpoint_changed(profile.imap_config, imap)
|
|
)
|
|
|
|
|
|
def _profile_has_stored_credentials(profile) -> bool:
|
|
return bool(
|
|
getattr(profile, "smtp_password_encrypted", None)
|
|
or getattr(profile, "imap_password_encrypted", None)
|
|
)
|
|
|
|
|
|
def _require_mailbox_read_scope(principal: ApiPrincipal) -> None:
|
|
_require_scope(principal, "mail:mailbox:read")
|
|
_require_scope(principal, "mail:profile:use")
|
|
|
|
|
|
def _principal_is_tenant_admin(principal: ApiPrincipal) -> bool:
|
|
return has_scope(principal, "tenant:*")
|
|
|
|
|
|
def _principal_can_manage_tenant_profiles(principal: ApiPrincipal) -> bool:
|
|
return has_scope(principal, "mail:profile:write")
|
|
|
|
|
|
def _principal_can_manage_own_profiles(principal: ApiPrincipal) -> bool:
|
|
return has_scope(principal, "mail:profile:write_own")
|
|
|
|
|
|
def _principal_can_manage_system_profiles(principal: ApiPrincipal) -> bool:
|
|
return has_scope(principal, "system:settings:write")
|
|
|
|
|
|
def _profile_actor_kwargs(
|
|
principal: ApiPrincipal,
|
|
*,
|
|
administrative_visibility: bool,
|
|
) -> dict[str, object]:
|
|
return {
|
|
"actor_user_id": principal.user.id,
|
|
"actor_group_ids": principal.group_ids,
|
|
"actor_can_manage_own_profiles": _principal_can_manage_own_profiles(principal),
|
|
"actor_can_manage_tenant_profiles": _principal_can_manage_tenant_profiles(principal),
|
|
"actor_can_manage_system_profiles": _principal_can_manage_system_profiles(principal),
|
|
"actor_tenant_admin": _principal_is_tenant_admin(principal),
|
|
"actor_administrative_visibility": administrative_visibility,
|
|
}
|
|
|
|
|
|
def _get_profile_for_principal(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
profile_id: str,
|
|
require_active: bool = False,
|
|
administrative_visibility: bool = False,
|
|
):
|
|
return get_mail_server_profile_for_actor(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
profile_id=profile_id,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
can_manage_own_profiles=_principal_can_manage_own_profiles(principal),
|
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
|
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
|
tenant_admin=_principal_is_tenant_admin(principal),
|
|
administrative_visibility=administrative_visibility,
|
|
require_active=require_active,
|
|
)
|
|
|
|
|
|
def _require_policy_read_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
|
if scope_type == "system":
|
|
_require_scope(principal, "system:settings:read")
|
|
elif scope_type == "tenant":
|
|
_require_any_scope(principal, "admin:policies:read", "mail:profile:read")
|
|
else:
|
|
_require_any_scope(principal, "admin:policies:read", "mail:profile:read", "campaigns:campaign:read")
|
|
|
|
|
|
def _require_policy_write_scope(principal: ApiPrincipal, scope_type: str) -> None:
|
|
if scope_type == "system":
|
|
_require_scope(principal, "system:settings:write")
|
|
elif scope_type == "tenant":
|
|
_require_scope(principal, "admin:policies:write")
|
|
else:
|
|
_require_any_scope(principal, "admin:policies:write", "mail:profile:write")
|
|
|
|
|
|
def _require_policy_scope_visibility(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
) -> None:
|
|
if scope_type in {"system", "tenant"}:
|
|
return
|
|
if has_scope(principal, "admin:policies:read"):
|
|
return
|
|
if not mail_profile_scope_visible_to_actor(
|
|
session,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
profile_tenant_id=principal.tenant_id,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
|
tenant_admin=_principal_is_tenant_admin(principal),
|
|
):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mail profile policy not found")
|
|
|
|
|
|
def _require_campaign_context_visibility(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
campaign_id: str | None,
|
|
) -> None:
|
|
if not campaign_id:
|
|
return
|
|
if not campaign_mail_context_visible_to_actor(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
tenant_admin=_principal_is_tenant_admin(principal),
|
|
):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
|
|
|
|
|
|
def _require_policy_campaign_context(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
campaign_id: str | None,
|
|
) -> None:
|
|
if scope_type == "campaign" and campaign_id and campaign_id != scope_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="campaign_id must match the campaign policy scope_id",
|
|
)
|
|
_require_campaign_context_visibility(
|
|
session,
|
|
principal=principal,
|
|
campaign_id=campaign_id or (scope_id if scope_type == "campaign" else None),
|
|
)
|
|
|
|
|
|
def _require_campaign_profile_mutation_access(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
) -> None:
|
|
if scope_type != "campaign":
|
|
return
|
|
_require_campaign_context_visibility(
|
|
session,
|
|
principal=principal,
|
|
campaign_id=scope_id,
|
|
)
|
|
|
|
|
|
def _mail_hierarchy_context(
|
|
principal: ApiPrincipal,
|
|
*,
|
|
target_scope_type: str | None = None,
|
|
target_scope_id: str | None = None,
|
|
administrative: bool = False,
|
|
) -> MailHierarchyContext:
|
|
resolved_scope_type = target_scope_type or "user"
|
|
resolved_scope_id = (
|
|
target_scope_id
|
|
if target_scope_type is not None
|
|
else principal.user.id
|
|
)
|
|
return MailHierarchyContext(
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
group_ids=frozenset(str(item) for item in principal.group_ids),
|
|
target_scope_type=resolved_scope_type,
|
|
target_scope_id=resolved_scope_id,
|
|
administrative=administrative,
|
|
)
|
|
|
|
|
|
def _profile_response(
|
|
profile,
|
|
*,
|
|
session: Session | None = None,
|
|
principal: ApiPrincipal | None = None,
|
|
target_scope_type: str | None = None,
|
|
target_scope_id: str | None = None,
|
|
include_inactive: bool = False,
|
|
administrative: bool = False,
|
|
) -> MailServerProfileResponse:
|
|
payload = profile_response_payload(profile)
|
|
if session is None or principal is None:
|
|
return MailServerProfileResponse.model_validate(payload)
|
|
context = _mail_hierarchy_context(
|
|
principal,
|
|
target_scope_type=target_scope_type,
|
|
target_scope_id=target_scope_id,
|
|
administrative=administrative,
|
|
)
|
|
servers = profile_hierarchy_payload(
|
|
session,
|
|
profile=profile,
|
|
context=context,
|
|
include_inactive=include_inactive,
|
|
)
|
|
payload["servers"] = servers
|
|
credentials: dict[str, dict[str, Any]] = {
|
|
"smtp": {"username": None},
|
|
"imap": {"username": None},
|
|
}
|
|
for protocol in ("smtp", "imap"):
|
|
candidates = [server for server in servers if server["protocol"] == protocol]
|
|
selected = next(
|
|
(
|
|
server
|
|
for server in candidates
|
|
if server["is_default"] and server["is_active"]
|
|
),
|
|
candidates[0] if candidates else None,
|
|
)
|
|
if selected is None:
|
|
continue
|
|
payload[protocol] = dict(selected["config"])
|
|
bound_credentials = list(selected.get("credentials") or [])
|
|
selected_credential = next(
|
|
(
|
|
credential
|
|
for credential in bound_credentials
|
|
if credential.get("is_default") and credential.get("is_active")
|
|
),
|
|
bound_credentials[0] if bound_credentials else None,
|
|
)
|
|
if selected_credential is not None:
|
|
credentials[protocol] = {
|
|
"username": (
|
|
selected_credential.get("public_data") or {}
|
|
).get("username")
|
|
}
|
|
payload[f"{protocol}_password_configured"] = any(
|
|
credential.get("secret_configured")
|
|
for server in candidates
|
|
for credential in (server.get("credentials") or [])
|
|
)
|
|
payload["credentials"] = credentials
|
|
return MailServerProfileResponse.model_validate(payload)
|
|
|
|
|
|
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 _profile_change_visible_to_principal(
|
|
session: Session,
|
|
*,
|
|
entry: ChangeSequenceEntry,
|
|
principal: ApiPrincipal,
|
|
) -> bool:
|
|
try:
|
|
profile = get_mail_server_profile(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
profile_id=entry.resource_id,
|
|
)
|
|
except MailProfileError:
|
|
payload = entry.payload if isinstance(entry.payload, dict) else {}
|
|
return mail_profile_scope_visible_to_actor(
|
|
session,
|
|
scope_type=str(payload.get("scope_type") or "tenant"),
|
|
scope_id=str(payload["scope_id"]) if payload.get("scope_id") else None,
|
|
profile_tenant_id=entry.tenant_id,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
|
tenant_admin=_principal_is_tenant_admin(principal),
|
|
)
|
|
return mail_profile_visible_to_actor(
|
|
session,
|
|
profile=profile,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
can_manage_own_profiles=_principal_can_manage_own_profiles(principal),
|
|
can_manage_tenant_profiles=_principal_can_manage_tenant_profiles(principal),
|
|
can_manage_system_profiles=_principal_can_manage_system_profiles(principal),
|
|
tenant_admin=_principal_is_tenant_admin(principal),
|
|
administrative_visibility=True,
|
|
require_active=False,
|
|
)
|
|
|
|
|
|
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 _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 _mailbox_folder_response(
|
|
result,
|
|
*,
|
|
from_cache: bool = False,
|
|
refreshing: bool = False,
|
|
indexed_at=None,
|
|
) -> MailImapFolderListResponse:
|
|
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) 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,
|
|
from_cache=from_cache,
|
|
refreshing=refreshing,
|
|
indexed_at=indexed_at,
|
|
)
|
|
|
|
|
|
def _mailbox_messages_response(
|
|
*,
|
|
profile_id: str,
|
|
folder: str,
|
|
host: str | None,
|
|
port: int | None,
|
|
security: str | None,
|
|
total_count: int,
|
|
offset: int,
|
|
limit: int,
|
|
cursor: str | None,
|
|
next_cursor: str | None,
|
|
cursor_stable: bool,
|
|
full: bool,
|
|
messages,
|
|
from_cache: bool = False,
|
|
refreshing: bool = False,
|
|
indexed_at=None,
|
|
) -> MailMailboxMessageListResponse:
|
|
return MailMailboxMessageListResponse(
|
|
profile_id=profile_id,
|
|
folder=folder,
|
|
host=host,
|
|
port=port,
|
|
security=security,
|
|
total_count=total_count,
|
|
offset=offset,
|
|
limit=limit,
|
|
cursor=cursor,
|
|
next_cursor=next_cursor,
|
|
cursor_stable=cursor_stable,
|
|
full=full,
|
|
from_cache=from_cache,
|
|
refreshing=refreshing,
|
|
indexed_at=indexed_at,
|
|
messages=[_mailbox_summary_response(message) for message in messages],
|
|
)
|
|
|
|
|
|
def _cached_folder_selection(folders, requested: str, detected_sent_folder: str | None = None) -> str:
|
|
names = {folder.name for folder in folders}
|
|
if requested in names:
|
|
return requested
|
|
if "INBOX" in names:
|
|
return "INBOX"
|
|
if detected_sent_folder and detected_sent_folder in names:
|
|
return detected_sent_folder
|
|
return folders[0].name if folders else requested
|
|
|
|
|
|
def _transport_context_for_principal(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
campaign_id: str | None = None,
|
|
) -> MailHierarchyContext:
|
|
if not campaign_id:
|
|
return _mail_hierarchy_context(principal)
|
|
_require_campaign_context_visibility(
|
|
session,
|
|
principal=principal,
|
|
campaign_id=campaign_id,
|
|
)
|
|
campaign = campaign_mail_owner_context(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
)
|
|
return MailHierarchyContext(
|
|
tenant_id=principal.tenant_id,
|
|
user_id=campaign.owner_user_id,
|
|
group_ids=(
|
|
frozenset({campaign.owner_group_id})
|
|
if campaign.owner_group_id
|
|
else frozenset()
|
|
),
|
|
target_scope_type="campaign",
|
|
target_scope_id=campaign.id,
|
|
)
|
|
|
|
|
|
def _imap_config_for_principal(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
profile_id: str,
|
|
server_id: str | None = None,
|
|
credential_id: str | None = None,
|
|
campaign_id: str | None = None,
|
|
):
|
|
profile = _get_profile_for_principal(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
require_active=True,
|
|
)
|
|
try:
|
|
return resolve_mail_transport(
|
|
session,
|
|
profile=profile,
|
|
protocol="imap",
|
|
context=_transport_context_for_principal(
|
|
session,
|
|
principal=principal,
|
|
campaign_id=campaign_id,
|
|
),
|
|
server_id=server_id,
|
|
credential_id=credential_id,
|
|
).config
|
|
except MailServerHierarchyError as exc:
|
|
raise MailProfileError(str(exc)) from exc
|
|
|
|
|
|
def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None):
|
|
if scope_type == "system":
|
|
return None
|
|
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,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
include_inactive: bool,
|
|
campaign_id: str | None,
|
|
) -> MailSettingsDeltaResponse:
|
|
profiles = list_mail_server_profiles(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
include_inactive=include_inactive,
|
|
campaign_id=campaign_id,
|
|
**_profile_actor_kwargs(principal, administrative_visibility=True),
|
|
)
|
|
return MailSettingsDeltaResponse(
|
|
profiles=[
|
|
_profile_response(
|
|
profile,
|
|
session=session,
|
|
principal=principal,
|
|
target_scope_type=scope_type,
|
|
target_scope_id=scope_id,
|
|
include_inactive=include_inactive,
|
|
administrative=True,
|
|
)
|
|
for profile in profiles
|
|
],
|
|
policy=_policy_response(session, tenant_id=principal.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=principal.tenant_id),
|
|
has_more=False,
|
|
full=True,
|
|
)
|
|
|
|
|
|
@router.get("/address-lookup", response_model=MailAddressLookupResponse)
|
|
def lookup_mail_addresses(
|
|
query: str = Query(min_length=1),
|
|
limit: int = Query(default=25, ge=1, le=100),
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> MailAddressLookupResponse:
|
|
_require_scope(principal, "mail:profile:use")
|
|
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
|
|
if capability is None or not hasattr(capability, "lookup"):
|
|
return MailAddressLookupResponse(available=False, candidates=[])
|
|
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit)
|
|
return MailAddressLookupResponse(
|
|
available=True,
|
|
candidates=[MailAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates],
|
|
)
|
|
|
|
|
|
@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)
|
|
_require_policy_scope_visibility(
|
|
session,
|
|
principal=principal,
|
|
scope_type=clean_scope_type,
|
|
scope_id=scope_id,
|
|
)
|
|
_require_policy_campaign_context(
|
|
session,
|
|
principal=principal,
|
|
scope_type=clean_scope_type,
|
|
scope_id=scope_id,
|
|
campaign_id=campaign_id,
|
|
)
|
|
if since is None:
|
|
return _full_mail_settings_delta_response(
|
|
session,
|
|
principal=principal,
|
|
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,
|
|
principal=principal,
|
|
scope_type=clean_scope_type,
|
|
scope_id=scope_id,
|
|
include_inactive=include_inactive,
|
|
campaign_id=campaign_id,
|
|
)
|
|
visible_profile_entries = [
|
|
entry
|
|
for entry in entries
|
|
if entry.collection == MAIL_PROFILES_COLLECTION
|
|
and entry.resource_type == MAIL_PROFILE_RESOURCE
|
|
and _profile_change_visible_to_principal(session, entry=entry, principal=principal)
|
|
]
|
|
changed_profile_ids = {
|
|
entry.resource_id
|
|
for entry in visible_profile_entries
|
|
}
|
|
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,
|
|
**_profile_actor_kwargs(principal, administrative_visibility=True),
|
|
)
|
|
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 visible_profile_entries
|
|
if entry.resource_id not in visible_profiles
|
|
]
|
|
return MailSettingsDeltaResponse(
|
|
profiles=[
|
|
_profile_response(
|
|
profile,
|
|
session=session,
|
|
principal=principal,
|
|
target_scope_type=clean_scope_type,
|
|
target_scope_id=scope_id,
|
|
include_inactive=include_inactive,
|
|
administrative=True,
|
|
)
|
|
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,
|
|
campaign_id: str | None = Query(default=None),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
|
try:
|
|
profiles = list_mail_server_profiles(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
include_inactive=include_inactive,
|
|
campaign_id=campaign_id,
|
|
**_profile_actor_kwargs(principal, administrative_visibility=True),
|
|
)
|
|
return MailServerProfileListResponse(
|
|
profiles=[
|
|
_profile_response(
|
|
profile,
|
|
session=session,
|
|
principal=principal,
|
|
target_scope_type="campaign" if campaign_id else None,
|
|
target_scope_id=campaign_id,
|
|
include_inactive=include_inactive,
|
|
administrative=include_inactive,
|
|
)
|
|
for profile in profiles
|
|
]
|
|
)
|
|
except MailProfileError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post(
|
|
"/profiles/{profile_id}/servers",
|
|
response_model=MailServerEndpointResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_profile_server(
|
|
profile_id: str,
|
|
payload: MailServerEndpointCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile = _profile_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
)
|
|
server = create_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
protocol=payload.protocol,
|
|
name=payload.name,
|
|
config=payload.config,
|
|
user_id=principal.user.id,
|
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
|
is_default=payload.is_default,
|
|
is_active=payload.is_active,
|
|
)
|
|
_record_profile_child_change(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
operation="created",
|
|
resource_type=MAIL_SERVER_RESOURCE,
|
|
resource_id=server.id,
|
|
)
|
|
session.commit()
|
|
session.refresh(server)
|
|
return _server_response(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
server=server,
|
|
)
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.patch(
|
|
"/profiles/{profile_id}/servers/{server_id}",
|
|
response_model=MailServerEndpointResponse,
|
|
)
|
|
def update_profile_server(
|
|
profile_id: str,
|
|
server_id: str,
|
|
payload: MailServerEndpointUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile, server = _server_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
server_id=server_id,
|
|
)
|
|
update_mail_server_endpoint(
|
|
session,
|
|
server=server,
|
|
user_id=principal.user.id,
|
|
name=payload.name,
|
|
config=payload.config,
|
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
|
is_default=payload.is_default,
|
|
is_active=payload.is_active,
|
|
)
|
|
_record_profile_child_change(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
operation="updated",
|
|
resource_type=MAIL_SERVER_RESOURCE,
|
|
resource_id=server.id,
|
|
)
|
|
session.commit()
|
|
session.refresh(server)
|
|
return _server_response(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
server=server,
|
|
)
|
|
except MailServerHierarchyError as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.delete(
|
|
"/profiles/{profile_id}/servers/{server_id}",
|
|
response_model=MailServerEndpointResponse,
|
|
)
|
|
def deactivate_profile_server(
|
|
profile_id: str,
|
|
server_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile, server = _server_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
server_id=server_id,
|
|
)
|
|
update_mail_server_endpoint(
|
|
session,
|
|
server=server,
|
|
user_id=principal.user.id,
|
|
is_active=False,
|
|
)
|
|
_record_profile_child_change(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
operation="deactivated",
|
|
resource_type=MAIL_SERVER_RESOURCE,
|
|
resource_id=server.id,
|
|
)
|
|
session.commit()
|
|
session.refresh(server)
|
|
return _server_response(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
server=server,
|
|
)
|
|
except MailServerHierarchyError as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
def _credential_hierarchy_context(principal: ApiPrincipal, profile) -> MailHierarchyContext:
|
|
can_manage_all = has_scope(principal, "mail:secret:manage") or (
|
|
profile.scope_type == "system"
|
|
and has_scope(principal, "system:settings:write")
|
|
)
|
|
return hierarchy_context_for_profile(
|
|
profile,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
administrative=can_manage_all,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/profiles/{profile_id}/servers/{server_id}/available-credentials",
|
|
response_model=MailCredentialListResponse,
|
|
)
|
|
def list_profile_server_available_credentials(
|
|
profile_id: str,
|
|
server_id: str,
|
|
include_inactive: bool = False,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
profile = _get_profile_for_principal(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
administrative_visibility=True,
|
|
)
|
|
_require_profile_credentials_scope(
|
|
principal,
|
|
profile.scope_type or "tenant",
|
|
profile.scope_id,
|
|
)
|
|
context = _credential_hierarchy_context(principal, profile)
|
|
try:
|
|
server = get_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
server_id=server_id,
|
|
context=context,
|
|
require_active=False,
|
|
)
|
|
credentials = list_available_mail_credentials(
|
|
session,
|
|
context=context,
|
|
server_id=server.id,
|
|
include_inactive=include_inactive,
|
|
)
|
|
return MailCredentialListResponse(
|
|
credentials=[
|
|
MailCredentialEnvelopeResponse.model_validate(
|
|
mail_credential_payload(credential)
|
|
)
|
|
for credential in credentials
|
|
]
|
|
)
|
|
except MailServerHierarchyError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.post(
|
|
"/profiles/{profile_id}/servers/{server_id}/credentials",
|
|
response_model=MailCredentialEnvelopeResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_profile_server_credential(
|
|
profile_id: str,
|
|
server_id: str,
|
|
payload: MailCredentialCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile, server = _server_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
server_id=server_id,
|
|
)
|
|
_require_profile_credentials_scope(
|
|
principal,
|
|
profile.scope_type or "tenant",
|
|
profile.scope_id,
|
|
)
|
|
credential = create_and_bind_mail_credential(
|
|
session,
|
|
profile=profile,
|
|
server=server,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
credential_kind=payload.credential_kind,
|
|
username=payload.username,
|
|
password=payload.password,
|
|
public_data=payload.public_data,
|
|
secret_data=payload.secret_data,
|
|
user_id=principal.user.id,
|
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
|
allowed_modules=payload.allowed_modules,
|
|
allowed_server_refs=payload.allowed_server_refs,
|
|
is_default=payload.is_default,
|
|
)
|
|
binding, _ = get_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential.id,
|
|
context=_credential_hierarchy_context(principal, profile),
|
|
require_active=False,
|
|
)
|
|
_record_profile_child_change(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
operation="created",
|
|
resource_type=MAIL_CREDENTIAL_RESOURCE,
|
|
resource_id=credential.id,
|
|
)
|
|
session.commit()
|
|
session.refresh(credential)
|
|
return MailCredentialEnvelopeResponse.model_validate(
|
|
mail_credential_payload(
|
|
credential,
|
|
server_id=server.id,
|
|
binding_id=binding.id,
|
|
is_default=binding.is_default,
|
|
)
|
|
)
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.post(
|
|
"/profiles/{profile_id}/campaign-credentials",
|
|
response_model=MailCredentialEnvelopeResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_campaign_profile_credential(
|
|
profile_id: str,
|
|
payload: MailCampaignCredentialCreateRequest,
|
|
campaign_id: str = Query(...),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_any_scope(
|
|
principal,
|
|
"campaigns:campaign:update",
|
|
"mail:secret:manage",
|
|
)
|
|
_require_campaign_context_visibility(
|
|
session,
|
|
principal=principal,
|
|
campaign_id=campaign_id,
|
|
)
|
|
try:
|
|
profile = next(
|
|
(
|
|
item
|
|
for item in list_mail_server_profiles(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
campaign_id=campaign_id,
|
|
**_profile_actor_kwargs(
|
|
principal,
|
|
administrative_visibility=False,
|
|
),
|
|
)
|
|
if item.id == profile_id
|
|
),
|
|
None,
|
|
)
|
|
if profile is None:
|
|
raise MailServerHierarchyError("Mail-server profile not found")
|
|
context = hierarchy_context_for_profile(
|
|
profile,
|
|
user_id=principal.user.id,
|
|
group_ids=principal.group_ids,
|
|
target_scope_type="campaign",
|
|
target_scope_id=campaign_id,
|
|
)
|
|
server_ids = list(dict.fromkeys(item.strip() for item in payload.server_ids if item.strip()))
|
|
servers = [
|
|
get_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
server_id=server_id,
|
|
context=context,
|
|
require_active=True,
|
|
for_update=True,
|
|
)
|
|
for server_id in server_ids
|
|
]
|
|
if not servers:
|
|
raise MailServerHierarchyError("Select at least one mail server")
|
|
allowed_server_refs = [
|
|
ref
|
|
for ref in (mail_server_ref(server.id) for server in servers)
|
|
if ref is not None
|
|
]
|
|
credential = create_and_bind_mail_credential(
|
|
session,
|
|
profile=profile,
|
|
server=servers[0],
|
|
name=payload.name,
|
|
username=payload.username,
|
|
password=payload.password.get_secret_value(),
|
|
user_id=principal.user.id,
|
|
inherit_to_lower_scopes=False,
|
|
allowed_modules=("mail",),
|
|
allowed_server_refs=allowed_server_refs,
|
|
credential_scope_type="campaign",
|
|
credential_scope_id=campaign_id,
|
|
)
|
|
for server in servers[1:]:
|
|
bind_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential=credential,
|
|
user_id=principal.user.id,
|
|
)
|
|
first_binding, _ = get_bound_mail_credential(
|
|
session,
|
|
server=servers[0],
|
|
credential_id=credential.id,
|
|
context=context,
|
|
)
|
|
_record_profile_child_change(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
operation="created",
|
|
resource_type=MAIL_CREDENTIAL_RESOURCE,
|
|
resource_id=credential.id,
|
|
)
|
|
session.commit()
|
|
session.refresh(credential)
|
|
return MailCredentialEnvelopeResponse.model_validate(
|
|
mail_credential_payload(
|
|
credential,
|
|
server_id=servers[0].id,
|
|
binding_id=first_binding.id,
|
|
is_default=False,
|
|
)
|
|
)
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.post(
|
|
"/profiles/{profile_id}/servers/{server_id}/credential-bindings",
|
|
response_model=MailCredentialEnvelopeResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def bind_profile_server_credential(
|
|
profile_id: str,
|
|
server_id: str,
|
|
payload: MailCredentialBindRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile, server = _server_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
server_id=server_id,
|
|
)
|
|
_require_profile_credentials_scope(
|
|
principal,
|
|
profile.scope_type or "tenant",
|
|
profile.scope_id,
|
|
)
|
|
context = _credential_hierarchy_context(principal, profile)
|
|
credential = get_available_mail_credential(
|
|
session,
|
|
credential_id=payload.credential_id,
|
|
context=context,
|
|
server_id=server.id,
|
|
)
|
|
binding = bind_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential=credential,
|
|
user_id=principal.user.id,
|
|
is_default=payload.is_default,
|
|
)
|
|
_record_profile_child_change(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
operation="bound",
|
|
resource_type=MAIL_CREDENTIAL_RESOURCE,
|
|
resource_id=credential.id,
|
|
)
|
|
session.commit()
|
|
session.refresh(credential)
|
|
return MailCredentialEnvelopeResponse.model_validate(
|
|
mail_credential_payload(
|
|
credential,
|
|
server_id=server.id,
|
|
binding_id=binding.id,
|
|
is_default=binding.is_default,
|
|
)
|
|
)
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.patch(
|
|
"/profiles/{profile_id}/servers/{server_id}/credentials/{credential_id}",
|
|
response_model=MailCredentialEnvelopeResponse,
|
|
)
|
|
def update_profile_server_credential(
|
|
profile_id: str,
|
|
server_id: str,
|
|
credential_id: str,
|
|
payload: MailCredentialUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile, server = _server_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
server_id=server_id,
|
|
)
|
|
_require_profile_credentials_scope(
|
|
principal,
|
|
profile.scope_type or "tenant",
|
|
profile.scope_id,
|
|
)
|
|
context = _credential_hierarchy_context(principal, profile)
|
|
binding, credential = get_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential_id,
|
|
context=context,
|
|
require_active=False,
|
|
for_update=True,
|
|
)
|
|
update_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential=credential,
|
|
context=context,
|
|
user_id=principal.user.id,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
description_supplied="description" in payload.model_fields_set,
|
|
username=payload.username,
|
|
username_supplied="username" in payload.model_fields_set,
|
|
password=payload.password,
|
|
password_supplied="password" in payload.model_fields_set,
|
|
public_data=payload.public_data,
|
|
secret_data=payload.secret_data,
|
|
allowed_modules=payload.allowed_modules,
|
|
allowed_server_refs=payload.allowed_server_refs,
|
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
|
is_active=payload.is_active,
|
|
is_default=payload.is_default,
|
|
)
|
|
binding, credential = get_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential_id,
|
|
context=context,
|
|
require_active=False,
|
|
)
|
|
_record_profile_child_change(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
operation="updated",
|
|
resource_type=MAIL_CREDENTIAL_RESOURCE,
|
|
resource_id=credential.id,
|
|
)
|
|
session.commit()
|
|
session.refresh(credential)
|
|
return MailCredentialEnvelopeResponse.model_validate(
|
|
mail_credential_payload(
|
|
credential,
|
|
server_id=server.id,
|
|
binding_id=binding.id,
|
|
is_default=binding.is_default,
|
|
)
|
|
)
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.delete(
|
|
"/profiles/{profile_id}/servers/{server_id}/credentials/{credential_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
)
|
|
def unlink_profile_server_credential(
|
|
profile_id: str,
|
|
server_id: str,
|
|
credential_id: str,
|
|
retire_if_unused: bool = Query(default=False),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile, server = _server_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
server_id=server_id,
|
|
)
|
|
_require_profile_credentials_scope(
|
|
principal,
|
|
profile.scope_type or "tenant",
|
|
profile.scope_id,
|
|
)
|
|
context = _credential_hierarchy_context(principal, profile)
|
|
_, credential = get_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential_id,
|
|
context=context,
|
|
require_active=False,
|
|
)
|
|
unlink_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential=credential,
|
|
context=context,
|
|
user_id=principal.user.id,
|
|
retire_if_unused=retire_if_unused,
|
|
)
|
|
_record_profile_child_change(
|
|
session,
|
|
principal=principal,
|
|
profile=profile,
|
|
operation="unlinked",
|
|
resource_type=MAIL_CREDENTIAL_RESOURCE,
|
|
resource_id=credential.id,
|
|
)
|
|
session.commit()
|
|
return None
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
) from exc
|
|
|
|
|
|
@router.post("/profiles", response_model=MailServerProfileResponse, status_code=status.HTTP_201_CREATED)
|
|
def create_profile(
|
|
payload: MailServerProfileCreateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
authorization_scope_id = (
|
|
payload.scope_id or principal.user.id
|
|
if payload.scope_type == "user"
|
|
else payload.scope_id
|
|
)
|
|
_require_profile_write_scope(principal, payload.scope_type, authorization_scope_id)
|
|
_require_campaign_profile_mutation_access(
|
|
session,
|
|
principal=principal,
|
|
scope_type=payload.scope_type,
|
|
scope_id=payload.scope_id,
|
|
)
|
|
if payload.credentials_supplied():
|
|
_require_profile_credentials_scope(
|
|
principal,
|
|
payload.scope_type,
|
|
authorization_scope_id,
|
|
)
|
|
try:
|
|
smtp_config = payload.smtp_config()
|
|
imap_config = payload.imap_config()
|
|
profile = create_mail_server_profile(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
name=payload.name,
|
|
slug=payload.slug,
|
|
description=payload.description,
|
|
smtp=smtp_config,
|
|
imap=imap_config,
|
|
is_active=payload.is_active,
|
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
|
scope_type=payload.scope_type,
|
|
scope_id=payload.scope_id,
|
|
)
|
|
initialize_profile_hierarchy(
|
|
session,
|
|
profile=profile,
|
|
smtp=smtp_config,
|
|
imap=imap_config,
|
|
user_id=principal.user.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,
|
|
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
|
|
)
|
|
session.commit()
|
|
session.refresh(profile)
|
|
return _profile_response(
|
|
profile,
|
|
session=session,
|
|
principal=principal,
|
|
target_scope_type=profile.scope_type,
|
|
target_scope_id=profile.scope_id,
|
|
include_inactive=True,
|
|
administrative=True,
|
|
)
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
|
|
|
|
|
@router.get("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
|
def get_profile(
|
|
profile_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_any_scope(principal, "mail:profile:read", "system:settings:read")
|
|
try:
|
|
profile = _get_profile_for_principal(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
administrative_visibility=True,
|
|
)
|
|
return _profile_response(
|
|
profile,
|
|
session=session,
|
|
principal=principal,
|
|
target_scope_type=profile.scope_type,
|
|
target_scope_id=profile.scope_id,
|
|
include_inactive=True,
|
|
administrative=True,
|
|
)
|
|
except MailProfileError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
|
|
|
|
@router.patch("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
|
def update_profile(
|
|
profile_id: str,
|
|
payload: MailServerProfileUpdateRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile = _profile_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
)
|
|
_require_campaign_profile_mutation_access(
|
|
session,
|
|
principal=principal,
|
|
scope_type=profile.scope_type or "tenant",
|
|
scope_id=profile.scope_id,
|
|
)
|
|
smtp_config = payload.smtp_config()
|
|
if payload.smtp is None and "smtp" in payload.credentials.model_fields_set:
|
|
smtp_data = dict(profile.smtp_config or {})
|
|
smtp_data.update(payload.credentials.smtp.model_dump(mode="json", exclude_unset=True))
|
|
smtp_config = SmtpConfig.model_validate(smtp_data)
|
|
imap_config = payload.imap_config()
|
|
if payload.imap is None and "imap" in payload.credentials.model_fields_set and profile.imap_config:
|
|
imap_data = dict(profile.imap_config or {})
|
|
imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True))
|
|
imap_config = ImapConfig.model_validate(imap_data)
|
|
if _profile_update_requires_credentials_scope(
|
|
profile,
|
|
payload=payload,
|
|
smtp=smtp_config,
|
|
imap=imap_config,
|
|
):
|
|
_require_profile_credentials_scope(
|
|
principal,
|
|
profile.scope_type or "tenant",
|
|
profile.scope_id,
|
|
)
|
|
update_mail_server_profile(
|
|
session,
|
|
profile,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
api_key_id=principal.api_key_id,
|
|
name=payload.name,
|
|
slug=payload.slug,
|
|
description=payload.description if "description" in payload.model_fields_set else None,
|
|
is_active=payload.is_active,
|
|
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
|
smtp=smtp_config,
|
|
imap=imap_config,
|
|
clear_imap=payload.clear_imap,
|
|
)
|
|
smtp_server = None
|
|
if smtp_config is not None:
|
|
smtp_server = sync_default_profile_server(
|
|
session,
|
|
profile=profile,
|
|
protocol="smtp",
|
|
config=smtp_config.model_dump(mode="json"),
|
|
user_id=principal.user.id,
|
|
)
|
|
imap_server = None
|
|
if imap_config is not None or payload.clear_imap:
|
|
imap_server = sync_default_profile_server(
|
|
session,
|
|
profile=profile,
|
|
protocol="imap",
|
|
config=(
|
|
imap_config.model_dump(mode="json")
|
|
if imap_config is not None and not payload.clear_imap
|
|
else None
|
|
),
|
|
user_id=principal.user.id,
|
|
)
|
|
if smtp_server is not None and "smtp" in payload.credentials.model_fields_set:
|
|
smtp_credentials = payload.credentials.smtp
|
|
sync_default_profile_credential(
|
|
session,
|
|
profile=profile,
|
|
server=smtp_server,
|
|
username=smtp_credentials.username,
|
|
username_supplied="username" in smtp_credentials.model_fields_set,
|
|
password=smtp_credentials.password,
|
|
password_supplied="password" in smtp_credentials.model_fields_set,
|
|
user_id=principal.user.id,
|
|
)
|
|
if imap_server is not None and "imap" in payload.credentials.model_fields_set:
|
|
imap_credentials = payload.credentials.imap
|
|
sync_default_profile_credential(
|
|
session,
|
|
profile=profile,
|
|
server=imap_server,
|
|
username=imap_credentials.username,
|
|
username_supplied="username" in imap_credentials.model_fields_set,
|
|
password=imap_credentials.password,
|
|
password_supplied="password" in imap_credentials.model_fields_set,
|
|
user_id=principal.user.id,
|
|
)
|
|
_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,
|
|
payload={"scope_type": profile.scope_type, "scope_id": profile.scope_id},
|
|
)
|
|
session.commit()
|
|
session.refresh(profile)
|
|
return _profile_response(
|
|
profile,
|
|
session=session,
|
|
principal=principal,
|
|
target_scope_type=profile.scope_type,
|
|
target_scope_id=profile.scope_id,
|
|
include_inactive=True,
|
|
administrative=True,
|
|
)
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
session.rollback()
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
|
except Exception:
|
|
session.rollback()
|
|
raise
|
|
|
|
|
|
@router.delete("/profiles/{profile_id}", response_model=MailServerProfileResponse)
|
|
def deactivate_profile(
|
|
profile_id: str,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
try:
|
|
profile = _profile_for_mutation(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
)
|
|
_require_campaign_profile_mutation_access(
|
|
session,
|
|
principal=principal,
|
|
scope_type=profile.scope_type or "tenant",
|
|
scope_id=profile.scope_id,
|
|
)
|
|
if _profile_has_stored_credentials(profile):
|
|
_require_profile_credentials_scope(
|
|
principal,
|
|
profile.scope_type or "tenant",
|
|
profile.scope_id,
|
|
)
|
|
was_active = bool(profile.is_active)
|
|
profile.is_active = False
|
|
deleted_protocols = delete_mail_profile_credentials(
|
|
session,
|
|
profile=profile,
|
|
deletion_reason="profile_deactivated",
|
|
user_id=principal.user.id,
|
|
api_key_id=principal.api_key_id,
|
|
)
|
|
# Deactivation invalidates unauthenticated IMAP profiles too. Keep the
|
|
# cache deletion in this transaction so a failed audit/change record
|
|
# cannot leave profile state and cached mailbox data out of sync.
|
|
clear_mailbox_index(session, profile_id=profile.id)
|
|
session.add(profile)
|
|
if was_active or deleted_protocols:
|
|
_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,
|
|
payload={
|
|
"scope_type": profile.scope_type,
|
|
"scope_id": profile.scope_id,
|
|
"credentials_deleted": bool(deleted_protocols),
|
|
"deleted_credential_protocols": list(deleted_protocols),
|
|
},
|
|
)
|
|
session.commit()
|
|
session.refresh(profile)
|
|
return _profile_response(
|
|
profile,
|
|
session=session,
|
|
principal=principal,
|
|
target_scope_type=profile.scope_type,
|
|
target_scope_id=profile.scope_id,
|
|
include_inactive=True,
|
|
administrative=True,
|
|
)
|
|
except MailProfileError as exc:
|
|
session.rollback()
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
except Exception:
|
|
session.rollback()
|
|
raise
|
|
|
|
|
|
@router.get("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
|
|
def read_mail_profile_policy(
|
|
scope_type: str,
|
|
scope_id: str | None = Query(default=None),
|
|
campaign_id: str | None = Query(default=None),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
scope_type = scope_type.strip().casefold()
|
|
_require_policy_read_scope(principal, scope_type)
|
|
_require_policy_scope_visibility(
|
|
session,
|
|
principal=principal,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
)
|
|
_require_policy_campaign_context(
|
|
session,
|
|
principal=principal,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
campaign_id=campaign_id,
|
|
)
|
|
try:
|
|
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
|
|
|
|
|
|
@router.put("/policies/{scope_type}", response_model=MailProfilePolicyResponse)
|
|
def write_mail_profile_policy(
|
|
scope_type: str,
|
|
payload: MailProfilePolicyUpdateRequest,
|
|
scope_id: str | None = Query(default=None),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
scope_type = scope_type.strip().casefold()
|
|
_require_policy_write_scope(principal, scope_type)
|
|
_require_policy_scope_visibility(
|
|
session,
|
|
principal=principal,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
)
|
|
_require_policy_campaign_context(
|
|
session,
|
|
principal=principal,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
campaign_id=None,
|
|
)
|
|
try:
|
|
set_mail_profile_policy(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
policy=payload.policy.model_dump(mode="json"),
|
|
)
|
|
_record_mail_change(
|
|
session,
|
|
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},
|
|
)
|
|
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_CONTENT, detail=str(exc)) from exc
|
|
|
|
|
|
@router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse)
|
|
def test_profile_smtp(
|
|
profile_id: str,
|
|
server_id: str | None = Query(default=None),
|
|
credential_id: str | None = Query(default=None),
|
|
campaign_id: str | None = Query(default=None),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "mail:profile:test")
|
|
_require_scope(principal, "mail:profile:use")
|
|
try:
|
|
profile = _get_profile_for_principal(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
require_active=True,
|
|
)
|
|
if callable(getattr(session, "execute", None)):
|
|
smtp = resolve_mail_transport(
|
|
session,
|
|
profile=profile,
|
|
protocol="smtp",
|
|
context=_transport_context_for_principal(
|
|
session,
|
|
principal=principal,
|
|
campaign_id=campaign_id,
|
|
),
|
|
server_id=server_id,
|
|
credential_id=credential_id,
|
|
).config
|
|
else:
|
|
smtp = smtp_config_from_profile(profile)
|
|
result = test_smtp_login(smtp_config=smtp)
|
|
return MailConnectionTestResponse(ok=True, protocol="smtp", host=result.host, port=result.port, security=result.security, message="SMTP connection successful.", details={"authenticated": result.authenticated})
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
return MailConnectionTestResponse(ok=False, protocol="smtp", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
|
|
|
|
|
|
@router.post("/profiles/{profile_id}/test-imap", response_model=MailConnectionTestResponse)
|
|
def test_profile_imap(
|
|
profile_id: str,
|
|
server_id: str | None = Query(default=None),
|
|
credential_id: str | None = Query(default=None),
|
|
campaign_id: str | None = Query(default=None),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "mail:profile:test")
|
|
_require_scope(principal, "mail:profile:use")
|
|
try:
|
|
profile = _get_profile_for_principal(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
require_active=True,
|
|
)
|
|
if callable(getattr(session, "execute", None)):
|
|
imap = resolve_mail_transport(
|
|
session,
|
|
profile=profile,
|
|
protocol="imap",
|
|
context=_transport_context_for_principal(
|
|
session,
|
|
principal=principal,
|
|
campaign_id=campaign_id,
|
|
),
|
|
server_id=server_id,
|
|
credential_id=credential_id,
|
|
).config
|
|
else:
|
|
imap = imap_config_from_profile(profile)
|
|
if imap is None:
|
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
|
result = test_imap_login(imap_config=imap)
|
|
return MailConnectionTestResponse(ok=True, protocol="imap", host=result.host, port=result.port, security=result.security, message="IMAP connection successful.", details={"authenticated": result.authenticated})
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
return MailConnectionTestResponse(ok=False, protocol="imap", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__})
|
|
|
|
|
|
@router.post("/profiles/{profile_id}/list-imap-folders", response_model=MailImapFolderListResponse)
|
|
def list_profile_imap_folders(
|
|
profile_id: str,
|
|
server_id: str | None = Query(default=None),
|
|
credential_id: str | None = Query(default=None),
|
|
campaign_id: str | None = Query(default=None),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_scope(principal, "mail:profile:test")
|
|
_require_scope(principal, "mail:profile:use")
|
|
try:
|
|
profile = _get_profile_for_principal(
|
|
session,
|
|
principal=principal,
|
|
profile_id=profile_id,
|
|
require_active=True,
|
|
)
|
|
if callable(getattr(session, "execute", None)):
|
|
imap = resolve_mail_transport(
|
|
session,
|
|
profile=profile,
|
|
protocol="imap",
|
|
context=_transport_context_for_principal(
|
|
session,
|
|
principal=principal,
|
|
campaign_id=campaign_id,
|
|
),
|
|
server_id=server_id,
|
|
credential_id=credential_id,
|
|
).config
|
|
else:
|
|
imap = imap_config_from_profile(profile)
|
|
if imap is None:
|
|
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
|
result = list_imap_folders(imap_config=imap)
|
|
return _mailbox_folder_response(result)
|
|
except (MailProfileError, MailServerHierarchyError) as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
except Exception as exc:
|
|
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,
|
|
include_status: bool = Query(default=False),
|
|
refresh: bool = False,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_mailbox_read_scope(principal)
|
|
try:
|
|
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
|
|
if not include_status and not refresh:
|
|
cached = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
|
if cached is not None and not cached.stale:
|
|
result = SimpleNamespace(
|
|
host=imap.host,
|
|
port=imap.port,
|
|
security=imap.security.value,
|
|
folders=cached.folders,
|
|
detected_sent_folder=None,
|
|
)
|
|
return _mailbox_folder_response(result, from_cache=True, refreshing=False, indexed_at=cached.indexed_at)
|
|
result = list_imap_folders(imap_config=imap, include_status=include_status)
|
|
cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result)
|
|
session.commit()
|
|
return _mailbox_folder_response(result)
|
|
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_CONTENT, 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/bootstrap", response_model=MailMailboxBootstrapResponse)
|
|
def bootstrap_profile_mailbox(
|
|
profile_id: str,
|
|
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
|
limit: int = Query(default=DEFAULT_MAILBOX_MESSAGE_LIMIT, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0, le=100000),
|
|
refresh: bool = False,
|
|
include_status: bool = Query(default=False),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
) -> MailMailboxBootstrapResponse:
|
|
_require_mailbox_read_scope(principal)
|
|
try:
|
|
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
|
|
if not refresh:
|
|
cached_folders = cached_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
|
if cached_folders is not None and not cached_folders.stale:
|
|
selected_folder = _cached_folder_selection(cached_folders.folders, folder)
|
|
cached_messages = cached_mailbox_message_page(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
profile_id=profile_id,
|
|
folder=selected_folder,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
if cached_messages is not None and not cached_messages.stale:
|
|
folder_result = SimpleNamespace(
|
|
host=imap.host,
|
|
port=imap.port,
|
|
security=imap.security.value,
|
|
folders=cached_folders.folders,
|
|
detected_sent_folder=None,
|
|
)
|
|
next_cursor, cursor_stable = _next_mailbox_cursor(
|
|
tenant_id=principal.tenant_id,
|
|
profile_id=profile_id,
|
|
folder=cached_messages.folder,
|
|
limit=cached_messages.limit,
|
|
offset=cached_messages.offset,
|
|
total_count=cached_messages.total_count,
|
|
uidvalidity=cached_messages.uidvalidity,
|
|
messages=cached_messages.messages,
|
|
)
|
|
return MailMailboxBootstrapResponse(
|
|
profile_id=profile_id,
|
|
folder=cached_messages.folder,
|
|
folders=_mailbox_folder_response(
|
|
folder_result,
|
|
from_cache=True,
|
|
refreshing=False,
|
|
indexed_at=cached_folders.indexed_at,
|
|
),
|
|
messages=_mailbox_messages_response(
|
|
profile_id=profile_id,
|
|
folder=cached_messages.folder,
|
|
host=imap.host,
|
|
port=imap.port,
|
|
security=imap.security.value,
|
|
total_count=cached_messages.total_count,
|
|
offset=cached_messages.offset,
|
|
limit=cached_messages.limit,
|
|
cursor=None,
|
|
next_cursor=next_cursor,
|
|
cursor_stable=cursor_stable,
|
|
full=not cursor_stable,
|
|
messages=cached_messages.messages,
|
|
from_cache=True,
|
|
refreshing=False,
|
|
indexed_at=cached_messages.indexed_at,
|
|
),
|
|
)
|
|
|
|
result = load_imap_mailbox_bootstrap(
|
|
imap_config=imap,
|
|
folder=folder,
|
|
limit=limit,
|
|
offset=offset,
|
|
include_folder_status=include_status,
|
|
)
|
|
cache_mailbox_folders(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result.folders)
|
|
cache_mailbox_messages(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result.messages)
|
|
session.commit()
|
|
next_cursor, cursor_stable = _next_mailbox_cursor(
|
|
tenant_id=principal.tenant_id,
|
|
profile_id=profile_id,
|
|
folder=result.messages.folder,
|
|
limit=result.messages.limit,
|
|
offset=result.messages.offset,
|
|
total_count=result.messages.total_count,
|
|
uidvalidity=result.messages.uidvalidity,
|
|
messages=result.messages.messages,
|
|
)
|
|
return MailMailboxBootstrapResponse(
|
|
profile_id=profile_id,
|
|
folder=result.messages.folder,
|
|
folders=_mailbox_folder_response(result.folders),
|
|
messages=_mailbox_messages_response(
|
|
profile_id=profile_id,
|
|
folder=result.messages.folder,
|
|
host=result.messages.host,
|
|
port=result.messages.port,
|
|
security=result.messages.security,
|
|
total_count=result.messages.total_count,
|
|
offset=result.messages.offset,
|
|
limit=result.messages.limit,
|
|
cursor=None,
|
|
next_cursor=next_cursor,
|
|
cursor_stable=cursor_stable,
|
|
full=not cursor_stable,
|
|
messages=result.messages.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_CONTENT, 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 | None = Query(default=None, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0, le=100000),
|
|
cursor: str | None = None,
|
|
refresh: bool = False,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
session: Session = Depends(get_session),
|
|
):
|
|
_require_mailbox_read_scope(principal)
|
|
try:
|
|
imap = _imap_config_for_principal(session, principal=principal, profile_id=profile_id)
|
|
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)
|
|
if not refresh:
|
|
cached = cached_mailbox_message_page(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
profile_id=profile_id,
|
|
folder=folder,
|
|
limit=effective_limit,
|
|
offset=effective_offset,
|
|
)
|
|
cache_matches_cursor = cached is not None and (
|
|
cursor is None or (expected_uidvalidity is not None and cached.uidvalidity == expected_uidvalidity)
|
|
)
|
|
if cached is not None and cache_matches_cursor and not cached.stale:
|
|
next_cursor, cursor_stable = _next_mailbox_cursor(
|
|
tenant_id=principal.tenant_id,
|
|
profile_id=profile_id,
|
|
folder=cached.folder,
|
|
limit=cached.limit,
|
|
offset=cached.offset,
|
|
total_count=cached.total_count,
|
|
uidvalidity=cached.uidvalidity,
|
|
messages=cached.messages,
|
|
)
|
|
return _mailbox_messages_response(
|
|
profile_id=profile_id,
|
|
folder=cached.folder,
|
|
host=imap.host,
|
|
port=imap.port,
|
|
security=imap.security.value,
|
|
total_count=cached.total_count,
|
|
offset=cached.offset,
|
|
limit=cached.limit,
|
|
cursor=cursor,
|
|
next_cursor=next_cursor,
|
|
cursor_stable=cursor_stable,
|
|
full=not cursor_stable,
|
|
messages=cached.messages,
|
|
from_cache=True,
|
|
refreshing=False,
|
|
indexed_at=cached.indexed_at,
|
|
)
|
|
result = list_imap_messages(
|
|
imap_config=imap,
|
|
folder=folder,
|
|
limit=effective_limit,
|
|
offset=effective_offset,
|
|
after_uid=after_uid,
|
|
expected_uidvalidity=expected_uidvalidity,
|
|
)
|
|
cache_mailbox_messages(session, tenant_id=principal.tenant_id, profile_id=profile_id, result=result)
|
|
session.commit()
|
|
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 _mailbox_messages_response(
|
|
profile_id=profile_id,
|
|
folder=result.folder,
|
|
host=result.host,
|
|
port=result.port,
|
|
security=result.security,
|
|
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=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_CONTENT, 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_principal(session, principal=principal, 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_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
|
|
|
|
|
|
def _safe_error_message(exc: Exception) -> str:
|
|
text = str(exc).strip()
|
|
return text or exc.__class__.__name__
|
|
|
|
|
|
@router.post("/test-smtp", response_model=MailConnectionTestResponse)
|
|
def test_smtp_settings(
|
|
payload: MailSmtpTestRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
):
|
|
"""Test SMTP connectivity/login without sending any message."""
|
|
|
|
if not has_scope(principal, "mail:profile:test"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
|
if not has_scope(principal, "mail:secret:manage"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
|
try:
|
|
result = test_smtp_login(smtp_config=payload)
|
|
return MailConnectionTestResponse(
|
|
ok=True,
|
|
protocol="smtp",
|
|
host=result.host,
|
|
port=result.port,
|
|
security=result.security,
|
|
message="SMTP connection successful.",
|
|
details={"authenticated": result.authenticated},
|
|
)
|
|
except Exception as exc:
|
|
return MailConnectionTestResponse(
|
|
ok=False,
|
|
protocol="smtp",
|
|
host=payload.host,
|
|
port=payload.port,
|
|
security=payload.security.value,
|
|
message=_safe_error_message(exc),
|
|
details={"error_type": exc.__class__.__name__},
|
|
)
|
|
|
|
|
|
@router.post("/test-imap", response_model=MailConnectionTestResponse)
|
|
def test_imap_settings(
|
|
payload: MailImapTestRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
):
|
|
"""Test IMAP connectivity/login without selecting or appending messages."""
|
|
|
|
if not has_scope(principal, "mail:profile:test"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
|
if not has_scope(principal, "mail:secret:manage"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
|
try:
|
|
result = test_imap_login(imap_config=payload)
|
|
return MailConnectionTestResponse(
|
|
ok=True,
|
|
protocol="imap",
|
|
host=result.host,
|
|
port=result.port,
|
|
security=result.security,
|
|
message="IMAP connection successful.",
|
|
details={"authenticated": result.authenticated},
|
|
)
|
|
except Exception as exc:
|
|
return MailConnectionTestResponse(
|
|
ok=False,
|
|
protocol="imap",
|
|
host=payload.host,
|
|
port=payload.port,
|
|
security=payload.security.value,
|
|
message=_safe_error_message(exc),
|
|
details={"error_type": exc.__class__.__name__},
|
|
)
|
|
|
|
|
|
@router.post("/list-imap-folders", response_model=MailImapFolderListResponse)
|
|
def list_imap_folder_settings(
|
|
payload: MailImapTestRequest,
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
):
|
|
"""List visible IMAP folders and return the best Sent-folder guess."""
|
|
|
|
if not has_scope(principal, "mail:profile:test"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:test")
|
|
if not has_scope(principal, "mail:secret:manage"):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
|
try:
|
|
result = list_imap_folders(imap_config=payload)
|
|
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) 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 Exception as exc:
|
|
return MailImapFolderListResponse(
|
|
ok=False,
|
|
host=payload.host,
|
|
port=payload.port,
|
|
security=payload.security.value,
|
|
message=_safe_error_message(exc),
|
|
folders=[],
|
|
detected_sent_folder=None,
|
|
details={"error_type": exc.__class__.__name__},
|
|
)
|