security: harden Campaign delivery effects and reconciliation
This commit is contained in:
@@ -6,8 +6,6 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.mail.config import ImapConfig, SmtpConfig
|
||||
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_configuration,
|
||||
public_campaign_payload,
|
||||
@@ -55,6 +53,7 @@ class CampaignVersionUpdateRequest(BaseModel):
|
||||
editor_state: dict[str, Any] | None = None
|
||||
source_filename: str | None = None
|
||||
source_base_path: str | None = None
|
||||
migrate_legacy_mail_settings: bool = False
|
||||
|
||||
|
||||
class CampaignVersionSetStepRequest(BaseModel):
|
||||
@@ -118,6 +117,7 @@ class CampaignVersionResponse(BaseModel):
|
||||
|
||||
class CampaignVersionDetailResponse(CampaignVersionResponse):
|
||||
raw_json: dict[str, Any]
|
||||
mail_profile_migration_required: bool = False
|
||||
|
||||
@field_validator("raw_json", mode="before")
|
||||
@classmethod
|
||||
@@ -412,8 +412,21 @@ class CampaignSendJobRequest(BaseModel):
|
||||
class CampaignResolveOutcomeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
decision: Literal["smtp_accepted", "not_sent"]
|
||||
note: str | None = None
|
||||
decision: Literal[
|
||||
"smtp_accepted",
|
||||
"not_sent",
|
||||
"imap_appended",
|
||||
"imap_not_appended",
|
||||
]
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_imap_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
|
||||
if self.decision.startswith("imap_"):
|
||||
self.note = (self.note or "").strip()
|
||||
if not self.note:
|
||||
raise ValueError("IMAP reconciliation requires an evidence note")
|
||||
return self
|
||||
|
||||
|
||||
class ValidateCampaignRequest(BaseModel):
|
||||
@@ -429,125 +442,6 @@ class BuildCampaignRequest(BaseModel):
|
||||
write_eml: bool = True
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
|
||||
|
||||
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
|
||||
|
||||
class MailCredentialPolicyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
inherit: bool | None = None
|
||||
allow_override: 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)
|
||||
|
||||
|
||||
class MailProfilePolicyUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
|
||||
|
||||
|
||||
class MailProfilePolicyResponse(BaseModel):
|
||||
scope_type: MailProfileScope
|
||||
scope_id: str | None = None
|
||||
policy: dict[str, Any]
|
||||
effective_policy: dict[str, Any] | None = None
|
||||
|
||||
|
||||
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
|
||||
scope_type: MailProfileScope = "tenant"
|
||||
scope_id: str | None = None
|
||||
smtp: SmtpConfig
|
||||
imap: ImapConfig | None = None
|
||||
|
||||
|
||||
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
|
||||
smtp: SmtpConfig | None = None
|
||||
imap: ImapConfig | None = None
|
||||
clear_imap: bool = False
|
||||
|
||||
|
||||
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
|
||||
smtp: dict[str, Any]
|
||||
imap: dict[str, Any] | None = None
|
||||
smtp_password_configured: bool = False
|
||||
imap_password_configured: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class MailServerProfileListResponse(BaseModel):
|
||||
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailConnectionTestResponse(BaseModel):
|
||||
ok: bool
|
||||
protocol: Literal["smtp", "imap"]
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
message: str
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailImapFolderResponse(BaseModel):
|
||||
name: str
|
||||
flags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailImapFolderListResponse(BaseModel):
|
||||
ok: bool
|
||||
protocol: Literal["imap"] = "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
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ApiKeyCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -630,13 +524,60 @@ class CampaignActionResponse(BaseModel):
|
||||
class ReportEmailRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
to: list[str]
|
||||
to: list[str] = Field(min_length=1, max_length=50)
|
||||
version_id: str | None = None
|
||||
include_jobs: bool = False
|
||||
attach_jobs_csv: bool = True
|
||||
attach_jobs_csv: bool = False
|
||||
attach_report_json: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
@field_validator("to", mode="before")
|
||||
@classmethod
|
||||
def normalize_and_validate_recipients(cls, value: Any) -> Any:
|
||||
if not isinstance(value, list):
|
||||
return value
|
||||
if not 1 <= len(value) <= 50:
|
||||
raise ValueError("report email requires between 1 and 50 recipients")
|
||||
recipients: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
raise ValueError("report recipients must be email-address strings")
|
||||
recipient = item.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)
|
||||
if (
|
||||
not local
|
||||
or not domain
|
||||
or any(character.isspace() for character in recipient)
|
||||
or any(character in ',;:<>[]()\\"' for character in recipient)
|
||||
or local.startswith(".")
|
||||
or local.endswith(".")
|
||||
or ".." in local
|
||||
or domain.startswith(".")
|
||||
or domain.endswith(".")
|
||||
or ".." in domain
|
||||
or any(
|
||||
not label
|
||||
or label.startswith("-")
|
||||
or label.endswith("-")
|
||||
or not all(character.isalnum() or character == "-" for character in label)
|
||||
for label in domain.split(".")
|
||||
)
|
||||
):
|
||||
raise ValueError("report recipients must be email addresses")
|
||||
key = recipient.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
recipients.append(recipient)
|
||||
return recipients
|
||||
|
||||
|
||||
class ReportEmailResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
Reference in New Issue
Block a user