from __future__ import annotations from datetime import datetime from typing import Annotated, Any, Literal from pydantic import ( BaseModel, BeforeValidator, ConfigDict, Field, ValidationInfo, field_validator, model_validator, ) from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_campaign.backend.campaign.mail_profile_boundary import ( public_campaign_editor_state, validate_campaign_editor_state, ) from govoplan_campaign.backend.response_security import ( public_campaign_configuration, public_campaign_payload, public_source_filename, ) class CampaignCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") config: dict[str, Any] source_filename: str | None = None source_base_path: str | None = None class CampaignUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") external_id: str | None = None name: str | None = None status: str | None = None description: str | None = None class CampaignLifecycleMutationRequest(BaseModel): model_config = ConfigDict(extra="forbid") expected_state_token: str = Field(min_length=64, max_length=64) class CampaignCopyRequest(CampaignLifecycleMutationRequest): source_version_id: str = Field(min_length=1, max_length=36) external_id: str | None = Field(default=None, min_length=1, max_length=255) name: str | None = Field(default=None, min_length=1, max_length=255) include_recipients: bool = True include_files: bool = True include_shares: bool = False include_policies: bool = True include_mail_profile: bool = True class CampaignScheduleCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") source_version_id: str = Field(min_length=1, max_length=36) name: str = Field(min_length=1, max_length=255) delivery_mode: Literal["manual", "autonomous"] = "manual" recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once" interval_count: int = Field(default=1, ge=1, le=365) timezone: str = Field(default="UTC", min_length=1, max_length=100) starts_at: datetime ends_at: datetime | None = None max_occurrences: int = Field(default=1, ge=1, le=1000) include_recipients: bool = True include_files: bool = True include_shares: bool = False include_policies: bool = True include_mail_profile: bool = True @model_validator(mode="after") def validate_schedule(self) -> "CampaignScheduleCreateRequest": if self.starts_at.tzinfo is None: raise ValueError("Campaign schedule start must include a timezone.") if self.ends_at is not None: if self.ends_at.tzinfo is None: raise ValueError("Campaign schedule end must include a timezone.") if self.ends_at <= self.starts_at: raise ValueError("Campaign schedule end must be after its start.") if self.recurrence_kind == "once": self.max_occurrences = 1 self.interval_count = 1 elif self.max_occurrences < 2: raise ValueError("A recurring campaign schedule needs at least two occurrences.") return self class CampaignScheduleStateRequest(BaseModel): model_config = ConfigDict(extra="forbid") active: bool base_revision: int = Field(ge=1) class CampaignScheduleOccurrenceResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str schedule_id: str scheduled_for: datetime status: str idempotency_key: str | None = None generated_campaign_id: str | None = None generated_version_id: str | None = None error: str | None = None delivery_command_ids: list[str] = Field(default_factory=list) recovery_state: str = "none" evidence: dict[str, object] = Field(default_factory=dict) last_checked_at: datetime | None = None created_at: datetime class CampaignScheduleResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str campaign_id: str source_version_id: str name: str delivery_mode: str recurrence_kind: str interval_count: int timezone: str starts_at: datetime next_fire_at: datetime | None = None ends_at: datetime | None = None max_occurrences: int occurrence_count: int active: bool resource_revision: int last_fired_at: datetime | None = None last_campaign_id: str | None = None last_error: str | None = None last_outcome: str | None = None last_recovery_state: str | None = None created_at: datetime updated_at: datetime occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list) class CampaignScheduleListResponse(BaseModel): items: list[CampaignScheduleResponse] class CampaignContentLibrarySaveRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1, max_length=300) description: str | None = Field(default=None, max_length=4000) kind: Literal["fragment", "campaign_part"] target: Literal["subject", "text", "html"] | None = None subject: str | None = Field(default=None, max_length=1000) text: str | None = Field(default=None, max_length=1_000_000) html: str | None = Field(default=None, max_length=2_000_000) body_mode: Literal["text", "html", "both"] = "both" locale: str = Field(default="de", min_length=2, max_length=35) visibility: Literal["personal", "tenant"] = "personal" @model_validator(mode="after") def validate_content(self) -> "CampaignContentLibrarySaveRequest": if self.kind == "fragment" and self.target is None: raise ValueError("A content fragment requires a target field.") values = { "subject": self.subject, "text": self.text, "html": self.html, } if self.kind == "fragment": selected = values[self.target or "text"] if not selected or not selected.strip(): raise ValueError("The selected fragment field is empty.") elif not any(value and value.strip() for value in (self.text, self.html)): raise ValueError("A campaign part requires text or HTML body content.") return self class CampaignCreateMinimalRequest(BaseModel): model_config = ConfigDict(extra="forbid") external_id: str name: str description: str | None = None current_flow: str = "create" current_step: str = "basics" class CampaignVersionUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") campaign_json: dict[str, Any] | None = None current_flow: str | None = None current_step: str | None = None workflow_state: str | None = None is_complete: bool | None = None editor_state: dict[str, Any] | None = None source_filename: str | None = None source_base_path: str | None = None migrate_legacy_mail_settings: bool = False base_revision: int | None = Field(default=None, ge=1) reconciliation_kind: Literal["none", "auto_merge", "manual"] = "none" resolved_conflict_paths: list[str] = Field(default_factory=list, max_length=100) @field_validator("editor_state") @classmethod def validate_editor_state( cls, value: dict[str, Any] | None ) -> dict[str, Any] | None: return validate_campaign_editor_state(value) if value is not None else None class CampaignVersionSetStepRequest(BaseModel): model_config = ConfigDict(extra="forbid") current_flow: str | None = None current_step: str class CampaignReviewDecisionRequest(BaseModel): model_config = ConfigDict(extra="forbid") job_id: str = Field(min_length=1, max_length=36) decision: Literal["accept"] = "accept" reason: str | None = Field(default=None, max_length=4_000) class CampaignReviewStateRequest(BaseModel): model_config = ConfigDict(extra="forbid") inspection_complete: bool = False reviewed_message_keys: list[str] = Field(default_factory=list) issue_decisions: list[CampaignReviewDecisionRequest] = Field( default_factory=list, max_length=100_000, ) class CampaignPartialValidationRequest(BaseModel): model_config = ConfigDict(extra="forbid") campaign_json: dict[str, Any] | None = None section: str | None = None class CampaignVersionResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str campaign_id: str version_number: int edit_revision: int = 1 strong_etag: str = "" schema_version: str source_filename: str | None = None workflow_state: str = "editing" current_flow: str = "manual" current_step: str | None = None is_complete: bool = False editor_state: dict[str, Any] = Field(default_factory=dict) autosaved_at: datetime | None = None published_at: datetime | None = None locked_at: datetime | None = None locked_by_user_id: str | None = None user_lock_state: Literal["temporary", "permanent"] | None = None user_locked_at: datetime | None = None user_locked_by_user_id: str | None = None created_at: datetime updated_at: datetime validation_summary: dict[str, Any] | None = None build_summary: dict[str, Any] | None = None execution_snapshot_hash: str | None = None execution_snapshot_at: datetime | None = None delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = ( None ) delivery_mode_selected_at: datetime | None = None archived_at: datetime | None = None archived_by_user_id: str | None = None @field_validator("editor_state", mode="before") @classmethod def remove_unsupported_editor_state( cls, value: Any, info: ValidationInfo ) -> dict[str, Any]: return public_campaign_editor_state( value, include_diagnostics=bool((info.context or {}).get("include_diagnostics")), ) @field_validator("source_filename", mode="before") @classmethod def remove_source_directory(cls, value: Any) -> str | None: return public_source_filename(value) @field_validator("validation_summary", "build_summary", mode="before") @classmethod def remove_internal_summary_fields(cls, value: Any, info: ValidationInfo) -> Any: return public_campaign_payload( value, include_diagnostics=bool((info.context or {}).get("include_diagnostics")), ) class CampaignVersionDetailResponse(CampaignVersionResponse): raw_json: dict[str, Any] mail_profile_migration_required: bool = False @field_validator("raw_json", mode="before") @classmethod def remove_internal_configuration_fields(cls, value: Any) -> Any: return public_campaign_configuration(value) class CampaignPartialValidationResponse(BaseModel): ok: bool section: str | None = None error_count: int warning_count: int info_count: int issues: list[dict[str, Any]] class CampaignResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str external_id: str name: str description: str | None = None status: str current_version_id: str | None = None owner_user_id: str | None = None owner_group_id: str | None = None created_at: datetime updated_at: datetime class CampaignLifecycleActionResponse(BaseModel): allowed: bool reason: str | None = None class CampaignLifecyclePolicyResponse(BaseModel): policy_id: str policy_version: str state_token: str actions: dict[str, CampaignLifecycleActionResponse] provenance: dict[str, Any] class CampaignCreateResponse(BaseModel): campaign: CampaignResponse version: CampaignVersionResponse class CampaignListResponse(BaseModel): campaigns: list[CampaignResponse] class CampaignWorkspaceResponse(BaseModel): campaign: CampaignResponse | None = None versions: list[CampaignVersionResponse] = Field(default_factory=list) current_version: CampaignVersionDetailResponse | None = None summary: dict[str, Any] | None = None selected_version_id: str | None = None class CampaignDeltaResponse(BaseModel): campaigns: list[CampaignResponse] = Field(default_factory=list) deleted: list[DeltaDeletedItem] = Field(default_factory=list) watermark: str | None = None has_more: bool = False full: bool = False total: int = 0 page: int = 1 page_size: int = 500 pages: int = 1 class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse): deleted: list[DeltaDeletedItem] = Field(default_factory=list) watermark: str | None = None has_more: bool = False full: bool = False class CampaignShareItem(BaseModel): model_config = ConfigDict(from_attributes=True) id: str campaign_id: str target_type: Literal["user", "group"] target_id: str permission: Literal["read", "write"] = "read" revoked_at: datetime | None = None class CampaignShareListResponse(BaseModel): shares: list[CampaignShareItem] total: int = 0 page: int = 1 page_size: int = 500 pages: int = 1 class CampaignShareTargetItem(BaseModel): id: str name: str secondary: str | None = None class CampaignShareTargetsResponse(BaseModel): users: list[CampaignShareTargetItem] = Field(default_factory=list) groups: list[CampaignShareTargetItem] = Field(default_factory=list) class CampaignShareUpsertRequest(BaseModel): model_config = ConfigDict(extra="forbid") target_type: Literal["user", "group"] target_id: str permission: Literal["read", "write"] = "read" class CampaignOwnerUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") owner_user_id: str | None = None owner_group_id: str | None = None RecipientImportColumnKind = Literal[ "ignore", "id", "active", "name", "from", "to", "cc", "bcc", "reply_to", "field", "new_field", "attachment_pattern", ] class RecipientImportColumnMappingPayload(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) column_index: int = Field(ge=0, alias="columnIndex") kind: RecipientImportColumnKind field_name: str | None = Field(default=None, max_length=255, alias="fieldName") new_field_name: str | None = Field( default=None, max_length=255, alias="newFieldName" ) class RecipientImportMappingProfilePayload(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) name: str = Field(min_length=1, max_length=255) column_count: int = Field(ge=0, le=500, alias="columnCount") headers: list[str] = Field(default_factory=list, max_length=500) normalized_headers: list[str] = Field( default_factory=list, max_length=500, alias="normalizedHeaders" ) ordered_header_fingerprint: str = Field( min_length=1, max_length=64, alias="orderedHeaderFingerprint" ) unordered_header_fingerprint: str = Field( min_length=1, max_length=64, alias="unorderedHeaderFingerprint" ) delimiter: Literal[",", ";", "\t"] header_rows: int = Field(ge=0, le=10, alias="headerRows") quoted: bool = True value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators") mappings: list[RecipientImportColumnMappingPayload] = Field( default_factory=list, max_length=500 ) @model_validator(mode="after") def validate_column_shape(self) -> "RecipientImportMappingProfilePayload": if len(self.headers) != self.column_count: raise ValueError("headers length must match columnCount") if len(self.normalized_headers) != self.column_count: raise ValueError("normalizedHeaders length must match columnCount") for mapping in self.mappings: if mapping.column_index >= self.column_count: raise ValueError("mapping columnIndex exceeds columnCount") return self class RecipientImportMappingProfileResponse(RecipientImportMappingProfilePayload): model_config = ConfigDict(from_attributes=True, populate_by_name=True) id: str created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") class RecipientImportMappingProfileListResponse(BaseModel): profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list) class CampaignAddressLookupCandidate(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 CampaignAddressLookupResponse(BaseModel): available: bool = False candidates: list[CampaignAddressLookupCandidate] = Field(default_factory=list) class CampaignRecipientAddressSource(BaseModel): source_id: str source_label: str source_kind: str source_revision: str recipient_count: int = 0 provenance: dict[str, Any] = Field(default_factory=dict) class CampaignRecipientAddressSourcesResponse(BaseModel): available: bool = False sources: list[CampaignRecipientAddressSource] = Field(default_factory=list) class CampaignPostboxCatalogResponse(BaseModel): available: bool = False postboxes: list[dict[str, Any]] = Field(default_factory=list) templates: list[dict[str, Any]] = Field(default_factory=list) organization_units: list[dict[str, Any]] = Field(default_factory=list) class CampaignCalendarCatalogResponse(BaseModel): available: bool = False reason: str | None = None calendars: list[dict[str, Any]] = Field(default_factory=list) class CampaignRecipientAddressSourceSnapshotRequest(BaseModel): model_config = ConfigDict(extra="forbid") source_id: str = Field(min_length=1) purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120) class CampaignRecipientSnapshotItem(BaseModel): contact_id: str display_name: str email: str email_label: str | None = None fields: dict[str, Any] = Field(default_factory=dict) provenance: dict[str, Any] = Field(default_factory=dict) class CampaignRecipientSnapshotExcludedItem(BaseModel): contact_id: str display_name: str channel: str target: str contact_point_id: str | None = None status: str reason_code: str | None = None explanation: str | None = None provenance: dict[str, Any] = Field(default_factory=dict) class CampaignRecipientAddressSourceSnapshotResponse(BaseModel): source_id: str source_label: str source_kind: str source_revision: str generated_at: str recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list) excluded: list[CampaignRecipientSnapshotExcludedItem] = Field(default_factory=list) included_count: int = 0 excluded_count: int = 0 purpose: str = "campaign_delivery" provenance: dict[str, Any] = Field(default_factory=dict) class CampaignDistributionListParameter(BaseModel): key: str value_type: str label: str | None = None required: bool = False default: Any = None allowed_values: list[Any] = Field(default_factory=list) minimum: float | None = None maximum: float | None = None pattern: str | None = None description: str | None = None class CampaignDistributionListSource(BaseModel): id: str tenant_id: str name: str revision_id: str revision: int definition_hash: str definition_kind: str = "static" description: str | None = None status: str = "active" entry_count: int = 0 read_only: bool = False stale: bool = False parameters: list[CampaignDistributionListParameter] = Field(default_factory=list) updated_at: datetime | None = None provenance: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict) class CampaignDistributionListSourcesResponse(BaseModel): available: bool = False expand_available: bool = False sources: list[CampaignDistributionListSource] = Field(default_factory=list) class CampaignDistributionListExpansionRequest(BaseModel): model_config = ConfigDict(extra="forbid") list_id: str = Field(min_length=1, max_length=36) revision: int | None = Field(default=None, ge=1) effective_at: datetime | None = None purpose: str = Field(default="campaign_delivery", min_length=1, max_length=120) requested_channels: list[Literal["email", "postal", "internal_mail", "portal"]] = Field( default_factory=list, max_length=4, ) parameters: dict[str, Any] = Field(default_factory=dict) idempotency_key: str | None = Field(default=None, min_length=1, max_length=255) class CampaignDistributionSourceReference(BaseModel): provider: str resource_type: str resource_id: str revision: str | None = None fingerprint: str | None = None label: str | None = None metadata: dict[str, Any] = Field(default_factory=dict) class CampaignDistributionExplanation(BaseModel): code: str message: str severity: str provider: str | None = None source: CampaignDistributionSourceReference | None = None provenance: dict[str, Any] = Field(default_factory=dict) class CampaignDistributionChannelCandidate(BaseModel): channel: str target: str target_key: str status: str contact_point_id: str | None = None locale: str | None = None preferred: bool = False reason_code: str | None = None explanation: str | None = None source: CampaignDistributionSourceReference | None = None decision_provenance: dict[str, Any] = Field(default_factory=dict) class CampaignDistributionRecipient(BaseModel): recipient_key: str display_name: str status: str channels: list[CampaignDistributionChannelCandidate] = Field(default_factory=list) identity_id: str | None = None account_id: str | None = None contact_id: str | None = None organization_unit_id: str | None = None function_id: str | None = None source_entry_ids: list[str] = Field(default_factory=list) explanations: list[CampaignDistributionExplanation] = Field(default_factory=list) attributes: dict[str, Any] = Field(default_factory=dict) provenance: dict[str, Any] = Field(default_factory=dict) class CampaignDistributionProviderEvidence(BaseModel): provider: str source: CampaignDistributionSourceReference actual_revision: str | None = None actual_fingerprint: str | None = None stale: bool = False generated_at: datetime | None = None details: dict[str, Any] = Field(default_factory=dict) class CampaignDistributionListExpansionResponse(BaseModel): source: CampaignDistributionListSource request: dict[str, Any] = Field(default_factory=dict) recipients: list[CampaignDistributionRecipient] = Field(default_factory=list) excluded: list[CampaignDistributionRecipient] = Field(default_factory=list) diagnostics: list[CampaignDistributionExplanation] = Field(default_factory=list) provider_evidence: list[CampaignDistributionProviderEvidence] = Field(default_factory=list) expansion_hash: str generated_at: datetime | None = None snapshot_id: str | None = None stale: bool = False truncated: bool = False class CampaignJobsResponse(BaseModel): jobs: list[dict[str, Any]] page: int = 1 page_size: int = 50 total: int = 0 total_unfiltered: int = 0 pages: int = 0 cursor: str | None = None next_cursor: str | None = None counts: dict[str, dict[str, int]] = Field(default_factory=dict) filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict) review: dict[str, Any] = Field(default_factory=dict) class CampaignJobsDeltaResponse(CampaignJobsResponse): deleted: list[DeltaDeletedItem] = Field(default_factory=list) watermark: str | None = None has_more: bool = False full: bool = False class CampaignJobDetailResponse(BaseModel): job: dict[str, Any] attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) class CampaignJobDiagnosticsResponse(BaseModel): job_id: str campaign_id: str campaign_version_id: str storage: dict[str, Any] = Field(default_factory=dict) worker_claim: dict[str, Any] = Field(default_factory=dict) attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) class CampaignRetryJobsRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None job_ids: list[str] = Field(default_factory=list) include_permanent: bool = False force_max_attempts: bool = False enqueue_celery: bool = True dry_run: bool = False class CampaignSendUnattemptedRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None job_ids: list[str] = Field(default_factory=list) enqueue_celery: bool = True dry_run: bool = False class CampaignSendJobRequest(BaseModel): model_config = ConfigDict(extra="forbid") kind: Literal["test", "single_send", "single_resend"] idempotency_key: str = Field(min_length=1, max_length=200) reason: str | None = Field(default=None, max_length=2000) context: dict[str, Any] = Field(default_factory=dict) include_warnings: bool = True use_rate_limit: bool = True enqueue_imap_task: bool = False @model_validator(mode="after") def require_resend_reason(self): self.reason = (self.reason or "").strip() or None if self.kind == "single_resend" and not self.reason: raise ValueError("single_resend requires a reason") return self class CampaignResolveOutcomeRequest(BaseModel): model_config = ConfigDict(extra="forbid") decision: Literal[ "smtp_accepted", "not_sent", "imap_appended", "imap_not_appended", "postbox_accepted", "postbox_not_accepted", ] note: str | None = Field(default=None, max_length=2000) attempt_id: str | None = Field(default=None, max_length=36) @model_validator(mode="after") def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest": self.note = (self.note or "").strip() if not self.note: raise ValueError("Reconciliation requires an evidence note") return self class ValidateCampaignRequest(BaseModel): model_config = ConfigDict(extra="forbid") check_files: bool = False link_unshared_matches: bool = False class BuildCampaignRequest(BaseModel): model_config = ConfigDict(extra="forbid") write_eml: bool = True idempotency_key: str | None = Field(default=None, min_length=1, max_length=200) class CampaignArtifactReconcileRequest(BaseModel): model_config = ConfigDict(extra="forbid") apply: bool = False idempotency_key: str | None = Field(default=None, min_length=1, max_length=200) grace_period_hours: int = Field(default=24, ge=24, le=24 * 90) cursor: str | None = Field(default=None, min_length=1, max_length=1000) page_size: int = Field(default=250, ge=1, le=1000) @model_validator(mode="after") def require_apply_idempotency_key(self) -> "CampaignArtifactReconcileRequest": if self.apply and not self.idempotency_key: raise ValueError("Applied artifact cleanup requires an idempotency key") return self class CampaignArtifactCandidateResponse(BaseModel): key: str size_bytes: int modified_at: datetime age_seconds: int reason: str disposition: str failure_type: str | None = None class CampaignArtifactReconcileResponse(BaseModel): apply: bool status: str recovery_operation_id: str | None = None tenant_prefix: str cursor: str | None = None next_cursor: str | None = None scanned_count: int scanned_bytes: int referenced_count: int active_build_count: int young_count: int unknown_age_count: int invalid_shape_count: int candidate_count: int candidate_bytes: int deleted_count: int deleted_bytes: int failure_count: int manifest_sha256: str | None = None candidates: list[CampaignArtifactCandidateResponse] = Field(default_factory=list) class ApiKeyCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str scopes: list[str] = Field(default_factory=list) class ApiKeyCreateResponse(BaseModel): id: str name: str prefix: str scopes: list[str] secret: str class QueueCampaignRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None include_warnings: bool = True enqueue_celery: bool = True dry_run: bool = False class QueueCampaignResponse(BaseModel): campaign_id: str version_id: str queued_count: int skipped_count: int blocked_count: int enqueued_count: int delivery_mode: str = "worker_queue" worker_queue_available: bool = False dry_run: bool = False class SendCampaignNowRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None include_warnings: bool = True check_files: bool = False validate_before_send: bool = False build_before_send: bool = False dry_run: bool = False use_rate_limit: bool = True enqueue_imap_task: bool = False class SendCampaignNowResponse(BaseModel): result: dict[str, Any] class CampaignDeliveryOptionsResponse(BaseModel): campaign_id: str version_id: str worker_queue_available: bool postbox_available: bool = False approval_gate: dict[str, Any] = Field(default_factory=dict) synchronous_send: dict[str, Any] = Field(default_factory=dict) class MockCampaignSendRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None send: bool = False include_warnings: bool = True include_needs_review: bool = False append_sent: bool = True clear_mailbox: bool = False check_files: bool = False class MockCampaignSendResponse(BaseModel): result: dict[str, Any] class AppendSentRequest(BaseModel): model_config = ConfigDict(extra="forbid") enqueue_celery: bool = True run_inline: bool = False dry_run: bool = False class CampaignActionResponse(BaseModel): result: dict[str, Any] def _valid_report_email_domain(domain: str) -> bool: if not domain or domain.startswith(".") or domain.endswith(".") or ".." in domain: return False return all( label and not label.startswith("-") and not label.endswith("-") and all(character.isalnum() or character == "-" for character in label) for label in domain.split(".") ) def _normalize_report_recipient(value: Any) -> str: if not isinstance(value, str): raise ValueError("report recipients must be email-address strings") recipient = value.strip() if len(recipient) > 320: raise ValueError("report recipient addresses must be at most 320 characters") if any(ord(character) < 32 or ord(character) == 127 for character in recipient): raise ValueError( "report recipient addresses must not contain control characters" ) if recipient.count("@") != 1: raise ValueError("report recipients must be email addresses") local, domain = recipient.split("@", 1) invalid_local = ( not local or local.startswith(".") or local.endswith(".") or ".." in local ) invalid_address = any(character.isspace() for character in recipient) or any( character in ',;:<>[]()\\"' for character in recipient ) if invalid_local or invalid_address or not _valid_report_email_domain(domain): raise ValueError("report recipients must be email addresses") return recipient ReportEmailAddress = Annotated[str, BeforeValidator(_normalize_report_recipient)] def _deduplicate_report_recipients(value: list[str]) -> list[str]: recipients: list[str] = [] seen: set[str] = set() for recipient in value: key = recipient.casefold() if key not in seen: seen.add(key) recipients.append(recipient) return recipients class ReportEmailRequest(BaseModel): model_config = ConfigDict(extra="forbid") to: list[ReportEmailAddress] = Field(min_length=1, max_length=50) version_id: str | None = None include_jobs: bool = False attach_jobs_csv: bool = False attach_report_json: bool = False dry_run: bool = False idempotency_key: str | None = Field(default=None, min_length=1, max_length=200) @field_validator("to") @classmethod def normalize_and_validate_recipients(cls, value: list[str]) -> list[str]: return _deduplicate_report_recipients(value) class ReportEmailResponse(BaseModel): result: dict[str, Any]