from __future__ import annotations from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_mail.backend.config import ( ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials, normalize_split_transport_credentials, ) class MailSmtpTestRequest(SmtpConfig): """SMTP settings supplied directly from the WebUI mail settings form.""" class MailImapTestRequest(ImapConfig): """IMAP settings supplied directly from the WebUI mail settings form.""" class MailTransportCredentialsPayload(TransportCredentials): """Credentials supplied separately from saved server settings.""" class MailServerProfileCredentialsPayload(BaseModel): model_config = ConfigDict(extra="forbid") smtp: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload) imap: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload) def _normalize_profile_transport_payload(value: object) -> object: return normalize_split_transport_credentials(value) def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]: payload = server.model_dump(mode="json") if "username" in credentials.model_fields_set: payload["username"] = credentials.username if "password" in credentials.model_fields_set: payload["password"] = credentials.password return payload MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"] class MailCredentialPolicyPayload(BaseModel): model_config = ConfigDict(extra="forbid") inherit: bool | None = None class MailProfilePolicyPayload(BaseModel): model_config = ConfigDict(extra="forbid") allowed_profile_ids: list[str] = Field(default_factory=list) allow_user_profiles: bool | None = None allow_group_profiles: bool | None = None allow_campaign_profiles: bool | None = None smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) whitelist: dict[str, list[str]] = Field(default_factory=dict) blacklist: dict[str, list[str]] = Field(default_factory=dict) allow_lower_level_limits: dict[str, bool] = Field(default_factory=dict) class MailProfilePolicyUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload) class PolicySourceStepResponse(BaseModel): scope_type: str scope_id: str | None = None path: str label: str applied_fields: list[str] = Field(default_factory=list) policy: dict[str, Any] = Field(default_factory=dict) class MailProfilePolicyResponse(BaseModel): scope_type: MailProfileScope scope_id: str | None = None policy: dict[str, Any] effective_policy: dict[str, Any] | None = None parent_policy: dict[str, Any] | None = None effective_policy_sources: list[PolicySourceStepResponse] = Field(default_factory=list) parent_policy_sources: list[PolicySourceStepResponse] = Field(default_factory=list) class MailServerProfileCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1, max_length=255) slug: str | None = Field(default=None, max_length=100) description: str | None = None is_active: bool = True inherit_to_lower_scopes: bool = True scope_type: MailProfileScope = "tenant" scope_id: str | None = None smtp: SmtpServerConfig imap: ImapServerConfig | None = None credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload) @model_validator(mode="before") @classmethod def normalize_legacy_transport_payload(cls, value: object) -> object: return _normalize_profile_transport_payload(value) def smtp_config(self) -> SmtpConfig: return SmtpConfig.model_validate(_merge_transport_credentials(self.smtp, self.credentials.smtp)) def imap_config(self) -> ImapConfig | None: if self.imap is None: return None return ImapConfig.model_validate(_merge_transport_credentials(self.imap, self.credentials.imap)) def credentials_supplied(self) -> bool: return any( field in credentials.model_fields_set for credentials in (self.credentials.smtp, self.credentials.imap) for field in ("username", "password") ) class MailServerProfileUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str | None = Field(default=None, max_length=255) slug: str | None = Field(default=None, max_length=100) description: str | None = None is_active: bool | None = None inherit_to_lower_scopes: bool | None = None smtp: SmtpServerConfig | None = None imap: ImapServerConfig | None = None credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload) clear_imap: bool = False @model_validator(mode="before") @classmethod def normalize_legacy_transport_payload(cls, value: object) -> object: return _normalize_profile_transport_payload(value) def smtp_config(self) -> SmtpConfig | None: if self.smtp is None and "smtp" not in self.credentials.model_fields_set: return None server = self.smtp or SmtpServerConfig() return SmtpConfig.model_validate(_merge_transport_credentials(server, self.credentials.smtp)) def imap_config(self) -> ImapConfig | None: if self.imap is None and "imap" not in self.credentials.model_fields_set: return None server = self.imap or ImapServerConfig() return ImapConfig.model_validate(_merge_transport_credentials(server, self.credentials.imap)) def credentials_supplied(self) -> bool: return any( field in credentials.model_fields_set for credentials in (self.credentials.smtp, self.credentials.imap) for field in ("username", "password") ) class MailCredentialEnvelopeResponse(BaseModel): id: str binding_id: str | None = None server_id: str | None = None tenant_id: str | None = None scope_type: MailProfileScope scope_id: str | None = None name: str description: str | None = None credential_kind: str public_data: dict[str, Any] = Field(default_factory=dict) secret_keys: list[str] = Field(default_factory=list) secret_configured: bool = False allowed_modules: list[str] = Field(default_factory=list) allowed_server_refs: list[str] = Field(default_factory=list) inherit_to_lower_scopes: bool = False is_default: bool = False is_active: bool = True revision: str created_at: datetime updated_at: datetime deleted_at: datetime | None = None class MailServerEndpointResponse(BaseModel): id: str profile_id: str tenant_id: str | None = None protocol: Literal["smtp", "imap", "jmap", "pop3"] name: str config: dict[str, Any] = Field(default_factory=dict) scope_type: MailProfileScope scope_id: str | None = None inherit_to_lower_scopes: bool = True is_default: bool = False is_active: bool = True transport_revision: str credentials: list[MailCredentialEnvelopeResponse] = Field(default_factory=list) created_at: datetime updated_at: datetime class MailServerEndpointCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") protocol: Literal["smtp", "imap", "jmap", "pop3"] name: str = Field(min_length=1, max_length=255) config: dict[str, Any] = Field(default_factory=dict) inherit_to_lower_scopes: bool | None = None is_default: bool = False is_active: bool = True class MailServerEndpointUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str | None = Field(default=None, max_length=255) config: dict[str, Any] | None = None inherit_to_lower_scopes: bool | None = None is_default: bool | None = None is_active: bool | None = None class MailCredentialCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1, max_length=255) description: str | None = None credential_kind: str = "username_password" username: str | None = None password: str | None = None public_data: dict[str, Any] = Field(default_factory=dict) secret_data: dict[str, Any] = Field(default_factory=dict) inherit_to_lower_scopes: bool | None = None allowed_modules: list[str] = Field(default_factory=lambda: ["mail"]) allowed_server_refs: list[str] = Field(default_factory=list) is_default: bool = False class MailCampaignCredentialCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1, max_length=255) username: str = Field(min_length=1, max_length=320) password: SecretStr server_ids: list[str] = Field(min_length=1) class MailCredentialBindRequest(BaseModel): model_config = ConfigDict(extra="forbid") credential_id: str = Field(min_length=1) is_default: bool = False class MailCredentialUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str | None = Field(default=None, max_length=255) description: str | None = None username: str | None = None password: str | None = None public_data: dict[str, Any] | None = None secret_data: dict[str, Any] | None = None inherit_to_lower_scopes: bool | None = None allowed_modules: list[str] | None = None allowed_server_refs: list[str] | None = None is_default: bool | None = None is_active: bool | None = None class MailCredentialUnlinkRequest(BaseModel): model_config = ConfigDict(extra="forbid") retire_if_unused: bool = False class MailCredentialListResponse(BaseModel): credentials: list[MailCredentialEnvelopeResponse] = Field(default_factory=list) class MailServerProfileResponse(BaseModel): id: str tenant_id: str | None = None scope_type: MailProfileScope = "tenant" scope_id: str | None = None name: str slug: str description: str | None = None is_active: bool inherit_to_lower_scopes: bool = True smtp: dict[str, Any] imap: dict[str, Any] | None = None credentials: dict[str, Any] = Field(default_factory=dict) smtp_password_configured: bool = False imap_password_configured: bool = False servers: list[MailServerEndpointResponse] = Field(default_factory=list) created_at: datetime updated_at: datetime class MailServerProfileListResponse(BaseModel): profiles: list[MailServerProfileResponse] = Field(default_factory=list) class MailSettingsDeltaResponse(BaseModel): profiles: list[MailServerProfileResponse] = Field(default_factory=list) policy: MailProfilePolicyResponse | None = None changed_sections: list[str] = Field(default_factory=list) deleted: list[DeltaDeletedItem] = Field(default_factory=list) watermark: str | None = None has_more: bool = False full: bool = False class MailAddressLookupCandidate(BaseModel): contact_id: str address_book_id: str display_name: str email: str | None = None email_label: str | None = None organization: str | None = None role_title: str | None = None tags: list[str] = Field(default_factory=list) source_kind: str = "local" source_ref: str | None = None source_revision: str | None = None provenance: dict[str, Any] = Field(default_factory=dict) class MailAddressLookupResponse(BaseModel): available: bool = False candidates: list[MailAddressLookupCandidate] = Field(default_factory=list) class MailAddressWriteTarget(BaseModel): address_book_id: str address_book_label: str | None = None operation: str = "create_contact" allowed: bool = False reason: str message: str scope_type: str | None = None scope_id: str | None = None source_kind: str | None = None read_only: bool = False required_scopes: list[str] = Field(default_factory=list) provenance: dict[str, Any] = Field(default_factory=dict) class MailAddressWriteTargetResponse(BaseModel): available: bool = False targets: list[MailAddressWriteTarget] = Field(default_factory=list) class MailContactCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") address_book_id: str = Field(min_length=1, max_length=36) display_name: str | None = Field(default=None, max_length=255) email: str = Field(min_length=3, max_length=320, pattern=r"^[^\s@]+@[^\s@]+\.[^\s@]+$") class MailContactCreateResponse(BaseModel): contact_id: str address_book_id: str display_name: str email: str | None = None source_kind: str = "local" provenance: dict[str, Any] = Field(default_factory=dict) class MailConnectionTestResponse(BaseModel): ok: bool protocol: Literal["smtp", "imap", "jmap", "pop3"] host: str | None = None port: int | None = None security: str | None = None message: str details: dict[str, Any] = Field(default_factory=dict) class MailPop3PreviewRequest(BaseModel): model_config = ConfigDict(extra="forbid") server_id: str = Field(min_length=1, max_length=36) credential_id: str | None = Field(default=None, max_length=36) limit: int = Field(default=50, ge=1, le=100) class MailPop3MessagePreviewResponse(BaseModel): message_number: int uidl: str subject: str | None = None from_header: str | None = None to_header: str | None = None date: str | None = None message_id: str | None = None size_bytes: int = 0 body_preview: str | None = None already_imported: bool = False class MailPop3PreviewResponse(BaseModel): profile_id: str server_id: str transport_revision: str host: str port: int security: str message_count: int mailbox_size_bytes: int delete_after_import_allowed: bool = False messages: list[MailPop3MessagePreviewResponse] = Field(default_factory=list) class MailPop3ImportRequest(BaseModel): model_config = ConfigDict(extra="forbid") server_id: str = Field(min_length=1, max_length=36) credential_id: str | None = Field(default=None, max_length=36) expected_transport_revision: str = Field(min_length=1, max_length=120) uidls: list[str] = Field(min_length=1, max_length=100) delete_after_import: bool = False class MailPop3ImportRecordResponse(BaseModel): id: str profile_id: str pop3_server_id: str transport_revision: str provider_uidl: str message_id: str | None = None subject: str | None = None from_header: str | None = None to_header: str | None = None date: str | None = None body_preview: str | None = None size_bytes: int raw_sha256: str status: str imported_at: datetime deletion_requested: bool = False deletion_status: str deletion_attempted_at: datetime | None = None deletion_error: str | None = None class MailPop3ImportResponse(BaseModel): imports: list[MailPop3ImportRecordResponse] = Field(default_factory=list) duplicate_uidls: list[str] = Field(default_factory=list) deletion_status: str = "not_requested" class MailPop3ImportListResponse(BaseModel): imports: list[MailPop3ImportRecordResponse] = Field(default_factory=list) class MailImapFolderResponse(BaseModel): name: str flags: list[str] = Field(default_factory=list) message_count: int | None = None unseen_count: int | None = None class MailImapFolderListResponse(BaseModel): ok: bool protocol: Literal["imap", "jmap"] = "imap" host: str | None = None port: int | None = None security: str | None = None message: str folders: list[MailImapFolderResponse] = Field(default_factory=list) detected_sent_folder: str | None = None detected_folder_mappings: dict[str, str] = Field(default_factory=dict) from_cache: bool = False refreshing: bool = False indexed_at: datetime | None = None details: dict[str, Any] = Field(default_factory=dict) class MailMailboxAttachmentResponse(BaseModel): filename: str | None = None content_type: str size_bytes: int class MailMailboxMessageSummaryResponse(BaseModel): uid: str folder: str subject: str | None = None from_header: str | None = None to_header: str | None = None cc_header: str | None = None date: str | None = None message_id: str | None = None flags: list[str] = Field(default_factory=list) size_bytes: int | None = None body_preview: str | None = None attachment_count: int = 0 class MailMailboxMessageDetailResponse(MailMailboxMessageSummaryResponse): body_text: str | None = None body_html: str | None = None headers: dict[str, str] = Field(default_factory=dict) attachments: list[MailMailboxAttachmentResponse] = Field(default_factory=list) class MailMailboxMessageListResponse(BaseModel): profile_id: str folder: str host: str | None = None port: int | None = None security: str | None = None total_count: int = 0 offset: int = 0 limit: int = 50 cursor: str | None = None next_cursor: str | None = None cursor_stable: bool = False full: bool = False from_cache: bool = False refreshing: bool = False indexed_at: datetime | None = None messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list) class MailMailboxChangesResponse(BaseModel): profile_id: str protocol: Literal["jmap"] = "jmap" account_id: str old_state: str new_state: str has_more_changes: bool = False created: list[str] = Field(default_factory=list) updated: list[str] = Field(default_factory=list) destroyed: list[str] = Field(default_factory=list) class MailMailboxBootstrapResponse(BaseModel): profile_id: str folder: str folders: MailImapFolderListResponse messages: MailMailboxMessageListResponse class MailMailboxMessageResponse(BaseModel): profile_id: str folder: str host: str | None = None port: int | None = None security: str | None = None message: MailMailboxMessageDetailResponse class MailDeliveryCommandResponse(BaseModel): result: dict[str, Any] class MailDeliveryReconcileRequest(BaseModel): model_config = ConfigDict(extra="forbid") decision: Literal["accepted", "not_accepted"] evidence_reference: str = Field(min_length=1, max_length=500) note: str | None = Field(default=None, max_length=4000) class MailDeliveryResendRequest(BaseModel): model_config = ConfigDict(extra="forbid") idempotency_key: str = Field(min_length=1, max_length=200) class MailBounceSourceRequest(BaseModel): model_config = ConfigDict(extra="forbid") profile_id: str = Field(min_length=1, max_length=36) folder: str = Field(default="INBOX", min_length=1, max_length=255) imap_server_id: str | None = Field(default=None, max_length=36) imap_credential_id: str | None = Field(default=None, max_length=36) is_active: bool = True class MailBounceSourceResponse(BaseModel): id: str tenant_id: str profile_id: str folder: str imap_server_id: str | None = None imap_credential_id: str | None = None expected_imap_transport_revision: str is_active: bool uidvalidity: str | None = None highest_processed_uid: int last_scanned_at: datetime | None = None last_success_at: datetime | None = None last_error: str | None = None class MailBounceSourceListResponse(BaseModel): sources: list[MailBounceSourceResponse] class MailBounceObservationResponse(BaseModel): id: str tenant_id: str profile_id: str folder: str uid: str original_message_id: str | None = None command_id: str | None = None recipient: str | None = None action: str status_code: str | None = None diagnostic: str | None = None permanent: bool observed_at: datetime matched: bool evidence: dict[str, Any] = Field(default_factory=dict) class MailBounceObservationListResponse(BaseModel): observations: list[MailBounceObservationResponse] class MailBounceScanResponse(BaseModel): sources: int processed_messages: int observations: int failures: list[dict[str, str]] = Field(default_factory=list)