Files
govoplan-campaign/src/govoplan_campaign/backend/schemas.py
T

1047 lines
32 KiB
Python

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]