feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -23,6 +23,8 @@ class FieldType(StrEnum):
|
||||
DOUBLE = "double"
|
||||
DATE = "date"
|
||||
PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary.
|
||||
ORGANIZATION_UNIT = "organization_unit"
|
||||
ORGANIZATION_FUNCTION = "organization_function"
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
@@ -92,6 +94,104 @@ class SendStatus(StrEnum):
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class DeliveryChannelPolicy(StrEnum):
|
||||
MAIL = "mail"
|
||||
POSTBOX = "postbox"
|
||||
MAIL_AND_POSTBOX = "mail_and_postbox"
|
||||
MAIL_THEN_POSTBOX = "mail_then_postbox"
|
||||
POSTBOX_THEN_MAIL = "postbox_then_mail"
|
||||
|
||||
@property
|
||||
def uses_mail(self) -> bool:
|
||||
return self in {
|
||||
DeliveryChannelPolicy.MAIL,
|
||||
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
|
||||
}
|
||||
|
||||
@property
|
||||
def uses_postbox(self) -> bool:
|
||||
return self != DeliveryChannelPolicy.MAIL
|
||||
|
||||
|
||||
class PostboxTargetMode(StrEnum):
|
||||
DIRECT = "direct"
|
||||
DERIVED = "derived"
|
||||
|
||||
|
||||
class PostboxTargetMatch(StrEnum):
|
||||
ID = "id"
|
||||
SLUG = "slug"
|
||||
|
||||
|
||||
class PostboxTargetConfig(StrictModel):
|
||||
id: str = Field(min_length=1, max_length=120)
|
||||
mode: PostboxTargetMode = PostboxTargetMode.DIRECT
|
||||
label: str | None = Field(default=None, max_length=500)
|
||||
|
||||
postbox_id: str | None = Field(default=None, max_length=36)
|
||||
address_key: str | None = Field(default=None, max_length=500)
|
||||
|
||||
template_id: str | None = Field(default=None, max_length=36)
|
||||
organization_unit_id: str | None = Field(default=None, max_length=36)
|
||||
organization_unit_field: str | None = Field(default=None, max_length=255)
|
||||
organization_unit_match: PostboxTargetMatch = PostboxTargetMatch.ID
|
||||
function_id: str | None = Field(default=None, max_length=36)
|
||||
function_field: str | None = Field(default=None, max_length=255)
|
||||
function_match: PostboxTargetMatch = PostboxTargetMatch.ID
|
||||
context_key: str | None = Field(default=None, max_length=255)
|
||||
context_field: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_target_shape(self) -> "PostboxTargetConfig":
|
||||
direct_values = [self.postbox_id, self.address_key]
|
||||
if self.mode == PostboxTargetMode.DIRECT:
|
||||
if sum(bool(value) for value in direct_values) != 1:
|
||||
raise ValueError(
|
||||
"A direct Postbox target requires exactly one postbox_id "
|
||||
"or address_key."
|
||||
)
|
||||
if any(
|
||||
(
|
||||
self.template_id,
|
||||
self.organization_unit_id,
|
||||
self.organization_unit_field,
|
||||
self.function_id,
|
||||
self.function_field,
|
||||
self.context_key,
|
||||
self.context_field,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"A direct Postbox target cannot contain derived target fields."
|
||||
)
|
||||
return self
|
||||
|
||||
if any(direct_values):
|
||||
raise ValueError(
|
||||
"A derived Postbox target cannot contain postbox_id or address_key."
|
||||
)
|
||||
if not self.template_id:
|
||||
raise ValueError("A derived Postbox target requires template_id.")
|
||||
if bool(self.organization_unit_id) == bool(self.organization_unit_field):
|
||||
raise ValueError(
|
||||
"A derived Postbox target requires exactly one fixed or "
|
||||
"field-derived organization unit."
|
||||
)
|
||||
if bool(self.function_id) == bool(self.function_field):
|
||||
raise ValueError(
|
||||
"A derived Postbox target requires exactly one fixed or "
|
||||
"field-derived function."
|
||||
)
|
||||
if self.context_key and self.context_field:
|
||||
raise ValueError(
|
||||
"A derived Postbox target may use a fixed context or a context "
|
||||
"field, not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class CampaignMeta(StrictModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -450,6 +550,13 @@ class EntryConfig(StrictModel):
|
||||
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
merge_disposition_notification_to: bool = True
|
||||
|
||||
channel_policy: DeliveryChannelPolicy | None = None
|
||||
postbox_targets: list[PostboxTargetConfig] = Field(
|
||||
default_factory=list,
|
||||
max_length=50,
|
||||
)
|
||||
merge_postbox_targets: bool = True
|
||||
|
||||
attachments: list[AttachmentConfig] = Field(default_factory=list)
|
||||
combine_attachments: bool = True
|
||||
|
||||
@@ -563,7 +670,17 @@ class RetryConfig(StrictModel):
|
||||
return values
|
||||
|
||||
|
||||
class PostboxDeliveryConfig(StrictModel):
|
||||
targets: list[PostboxTargetConfig] = Field(default_factory=list, max_length=50)
|
||||
classification: str = Field(default="internal", min_length=1, max_length=50)
|
||||
unresolved_target: Behavior = Behavior.BLOCK
|
||||
vacant_target: Behavior = Behavior.WARN
|
||||
duplicate_target: Behavior = Behavior.WARN
|
||||
|
||||
|
||||
class DeliveryConfig(StrictModel):
|
||||
channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL
|
||||
postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig)
|
||||
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
|
||||
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
|
||||
retry: RetryConfig = Field(default_factory=RetryConfig)
|
||||
@@ -606,3 +723,23 @@ class CampaignConfig(StrictModel):
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (campaign_file.parent / path).resolve()
|
||||
|
||||
|
||||
def effective_delivery_channel_policy(
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
) -> DeliveryChannelPolicy:
|
||||
return entry.channel_policy or config.delivery.channel_policy
|
||||
|
||||
|
||||
def effective_postbox_targets(
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
) -> list[PostboxTargetConfig]:
|
||||
global_targets = list(config.delivery.postbox.targets)
|
||||
individual_targets = list(entry.postbox_targets)
|
||||
if not individual_targets:
|
||||
return global_targets
|
||||
if entry.merge_postbox_targets:
|
||||
return [*global_targets, *individual_targets]
|
||||
return individual_targets
|
||||
|
||||
Reference in New Issue
Block a user