feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -44,6 +44,7 @@ def _parse_scalar_for_target(target: str, value: Any) -> Any:
|
||||
"merge_reply_to",
|
||||
"merge_bounce_to",
|
||||
"merge_disposition_notification_to",
|
||||
"merge_postbox_targets",
|
||||
"combine_to",
|
||||
"combine_cc",
|
||||
"combine_bcc",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.field_values import (
|
||||
effective_entry_field_values,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
Behavior,
|
||||
CampaignConfig,
|
||||
EntryConfig,
|
||||
PostboxTargetConfig,
|
||||
PostboxTargetMatch,
|
||||
PostboxTargetMode,
|
||||
effective_postbox_targets,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import postbox_integration
|
||||
from govoplan_campaign.backend.messages.models import (
|
||||
MessageIssue,
|
||||
MessageValidationStatus,
|
||||
)
|
||||
|
||||
|
||||
def _apply_behavior(
|
||||
current: MessageValidationStatus,
|
||||
behavior: Behavior,
|
||||
) -> MessageValidationStatus:
|
||||
if behavior == Behavior.BLOCK:
|
||||
return MessageValidationStatus.BLOCKED
|
||||
if behavior == Behavior.DROP:
|
||||
return MessageValidationStatus.EXCLUDED
|
||||
if behavior == Behavior.ASK and current not in {
|
||||
MessageValidationStatus.BLOCKED,
|
||||
MessageValidationStatus.EXCLUDED,
|
||||
}:
|
||||
return MessageValidationStatus.NEEDS_REVIEW
|
||||
if behavior == Behavior.WARN and current == MessageValidationStatus.READY:
|
||||
return MessageValidationStatus.WARNING
|
||||
return current
|
||||
|
||||
|
||||
def _issue(
|
||||
*,
|
||||
code: str,
|
||||
message: str,
|
||||
behavior: Behavior,
|
||||
) -> MessageIssue:
|
||||
return MessageIssue(
|
||||
severity="error" if behavior == Behavior.BLOCK else "warning",
|
||||
code=code,
|
||||
message=message,
|
||||
behavior=behavior.value,
|
||||
source="postbox",
|
||||
)
|
||||
|
||||
|
||||
def _field_value(
|
||||
values: dict[str, Any],
|
||||
field_name: str | None,
|
||||
) -> str | None:
|
||||
if not field_name:
|
||||
return None
|
||||
value = values.get(field_name)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _match_unit(
|
||||
catalog: PostboxDeliveryCatalogRef,
|
||||
value: str | None,
|
||||
match: PostboxTargetMatch,
|
||||
):
|
||||
if not value:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
unit
|
||||
for unit in catalog.organization_units
|
||||
if (unit.id if match == PostboxTargetMatch.ID else unit.slug) == value
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _match_function(unit, value: str | None, match: PostboxTargetMatch):
|
||||
if unit is None or not value:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
function
|
||||
for function in unit.functions
|
||||
if (
|
||||
function.id
|
||||
if match == PostboxTargetMatch.ID
|
||||
else function.slug
|
||||
)
|
||||
== value
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _target_ref(
|
||||
target: PostboxTargetConfig,
|
||||
*,
|
||||
values: dict[str, Any],
|
||||
catalog: PostboxDeliveryCatalogRef,
|
||||
) -> tuple[PostboxTargetRef | None, str | None]:
|
||||
if target.mode == PostboxTargetMode.DIRECT:
|
||||
return (
|
||||
PostboxTargetRef(
|
||||
postbox_id=target.postbox_id,
|
||||
address_key=target.address_key,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
unit_value = target.organization_unit_id or _field_value(
|
||||
values,
|
||||
target.organization_unit_field,
|
||||
)
|
||||
unit_match = (
|
||||
PostboxTargetMatch.ID
|
||||
if target.organization_unit_id
|
||||
else target.organization_unit_match
|
||||
)
|
||||
unit = _match_unit(catalog, unit_value, unit_match)
|
||||
if unit is None:
|
||||
return None, (
|
||||
f"Organization unit {unit_value!r} could not be resolved by "
|
||||
f"{unit_match.value}."
|
||||
)
|
||||
|
||||
function_value = target.function_id or _field_value(
|
||||
values,
|
||||
target.function_field,
|
||||
)
|
||||
function_match = (
|
||||
PostboxTargetMatch.ID
|
||||
if target.function_id
|
||||
else target.function_match
|
||||
)
|
||||
function = _match_function(unit, function_value, function_match)
|
||||
if function is None:
|
||||
return None, (
|
||||
f"Organization function {function_value!r} could not be resolved "
|
||||
f"inside {unit.name!r} by {function_match.value}."
|
||||
)
|
||||
|
||||
context_key = target.context_key or _field_value(
|
||||
values,
|
||||
target.context_field,
|
||||
)
|
||||
return (
|
||||
PostboxTargetRef(
|
||||
template_id=target.template_id,
|
||||
organization_unit_id=unit.id,
|
||||
function_id=function.id,
|
||||
context_key=context_key,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _resolved_target_payload(
|
||||
target: PostboxTargetConfig,
|
||||
entry: PostboxDirectoryEntryRef,
|
||||
*,
|
||||
position: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"target_id": target.id,
|
||||
"position": position,
|
||||
"mode": target.mode.value,
|
||||
"requested": target.model_dump(mode="json", exclude_none=True),
|
||||
"postbox_id": entry.id,
|
||||
"address": entry.address,
|
||||
"address_key": entry.address_key,
|
||||
"name": entry.name,
|
||||
"status": entry.status,
|
||||
"classification": entry.classification,
|
||||
"organization_unit_id": entry.organization_unit_id,
|
||||
"organization_unit_name": entry.organization_unit_name,
|
||||
"function_id": entry.function_id,
|
||||
"function_name": entry.function_name,
|
||||
"context_key": entry.context_key,
|
||||
"template_revision_id": entry.template_revision_id,
|
||||
"holder_count": entry.holder_count,
|
||||
"vacant": entry.vacant,
|
||||
}
|
||||
|
||||
|
||||
def resolve_entry_postbox_targets(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
validation_status: MessageValidationStatus,
|
||||
materialize: bool,
|
||||
) -> tuple[
|
||||
list[dict[str, Any]],
|
||||
list[MessageIssue],
|
||||
MessageValidationStatus,
|
||||
]:
|
||||
integration = postbox_integration()
|
||||
policy = config.delivery.postbox
|
||||
targets = effective_postbox_targets(config, entry)
|
||||
if not targets:
|
||||
issue = _issue(
|
||||
code="postbox_target_missing",
|
||||
message="Postbox delivery requires at least one target.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
return (
|
||||
[],
|
||||
[issue],
|
||||
_apply_behavior(validation_status, policy.unresolved_target),
|
||||
)
|
||||
|
||||
try:
|
||||
catalog = integration.delivery_catalog(session, tenant_id=tenant_id)
|
||||
except Exception as exc:
|
||||
issue = _issue(
|
||||
code="postbox_unavailable",
|
||||
message=str(exc),
|
||||
behavior=Behavior.BLOCK,
|
||||
)
|
||||
return [], [issue], MessageValidationStatus.BLOCKED
|
||||
|
||||
values = effective_entry_field_values(config, entry)
|
||||
resolved: list[dict[str, Any]] = []
|
||||
issues: list[MessageIssue] = []
|
||||
status = validation_status
|
||||
seen_postbox_ids: set[str] = set()
|
||||
for position, target in enumerate(targets):
|
||||
target_ref, resolution_error = _target_ref(
|
||||
target,
|
||||
values=values,
|
||||
catalog=catalog,
|
||||
)
|
||||
if target_ref is None:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=resolution_error or "Postbox target could not be resolved.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
try:
|
||||
entry_ref = integration.resolve_postbox(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
target=target_ref,
|
||||
materialize=materialize,
|
||||
)
|
||||
except Exception as exc:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=f"Postbox target {target.id!r} could not be resolved: {exc}",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
if entry_ref is None:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=f"Postbox target {target.id!r} does not exist.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
if entry_ref.id in seen_postbox_ids:
|
||||
issue = _issue(
|
||||
code="postbox_target_duplicate",
|
||||
message=(
|
||||
f"Postbox {entry_ref.address!r} is selected more than once; "
|
||||
"it will receive one message."
|
||||
),
|
||||
behavior=policy.duplicate_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.duplicate_target)
|
||||
continue
|
||||
seen_postbox_ids.add(entry_ref.id)
|
||||
resolved.append(
|
||||
_resolved_target_payload(
|
||||
target,
|
||||
entry_ref,
|
||||
position=position,
|
||||
)
|
||||
)
|
||||
if entry_ref.vacant:
|
||||
issue = _issue(
|
||||
code="postbox_target_vacant",
|
||||
message=(
|
||||
f"Postbox {entry_ref.address!r} currently has no function "
|
||||
"holder."
|
||||
),
|
||||
behavior=policy.vacant_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.vacant_target)
|
||||
return resolved, issues, status
|
||||
|
||||
|
||||
def delivery_catalog_payload(catalog: PostboxDeliveryCatalogRef) -> dict[str, Any]:
|
||||
return asdict(catalog)
|
||||
@@ -10,7 +10,21 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .addressing import effective_address_lists
|
||||
from .field_values import ignored_entry_field_overrides
|
||||
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
|
||||
from .models import (
|
||||
AttachmentConfig,
|
||||
CampaignConfig,
|
||||
DeliveryChannelPolicy,
|
||||
EntryConfig,
|
||||
FieldType,
|
||||
PostboxTargetConfig,
|
||||
SourceType,
|
||||
ZipArchiveConfig,
|
||||
ZipPasswordMode,
|
||||
ZipPasswordScope,
|
||||
ZipRuleMode,
|
||||
effective_delivery_channel_policy,
|
||||
effective_postbox_targets,
|
||||
)
|
||||
from ..attachments.resolver import resolve_campaign_attachments
|
||||
|
||||
|
||||
@@ -90,6 +104,8 @@ def _mapping_target_known(target: str, field_names: set[str]) -> bool:
|
||||
"merge_reply_to",
|
||||
"merge_bounce_to",
|
||||
"merge_disposition_notification_to",
|
||||
"merge_postbox_targets",
|
||||
"channel_policy",
|
||||
"combine_to",
|
||||
"combine_cc",
|
||||
"combine_bcc",
|
||||
@@ -337,10 +353,148 @@ def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> li
|
||||
]
|
||||
|
||||
|
||||
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
def _active_delivery_entries(config: CampaignConfig) -> list[EntryConfig]:
|
||||
if config.entries.is_inline:
|
||||
return [
|
||||
entry
|
||||
for entry in (config.entries.inline or [])
|
||||
if entry.active
|
||||
]
|
||||
return [config.entries.defaults or EntryConfig()]
|
||||
|
||||
|
||||
def _delivery_policies(config: CampaignConfig) -> set[DeliveryChannelPolicy]:
|
||||
return {
|
||||
effective_delivery_channel_policy(config, entry)
|
||||
for entry in _active_delivery_entries(config)
|
||||
}
|
||||
|
||||
|
||||
def _postbox_target_field_issues(
|
||||
config: CampaignConfig,
|
||||
target: PostboxTargetConfig,
|
||||
path: str,
|
||||
) -> list[SemanticIssue]:
|
||||
definitions = {field.name: field for field in config.fields}
|
||||
checks = (
|
||||
(
|
||||
target.organization_unit_field,
|
||||
FieldType.ORGANIZATION_UNIT,
|
||||
"organization unit",
|
||||
"organization_unit_field",
|
||||
),
|
||||
(
|
||||
target.function_field,
|
||||
FieldType.ORGANIZATION_FUNCTION,
|
||||
"organization function",
|
||||
"function_field",
|
||||
),
|
||||
(target.context_field, None, "context", "context_field"),
|
||||
)
|
||||
issues: list[SemanticIssue] = []
|
||||
for field_name, expected_type, label, key in checks:
|
||||
if not field_name:
|
||||
continue
|
||||
definition = definitions.get(field_name)
|
||||
if definition is None:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"postbox_target_field_missing",
|
||||
f"Postbox {label} field {field_name!r} is not declared.",
|
||||
f"{path}/{key}",
|
||||
)
|
||||
)
|
||||
elif expected_type is not None and definition.type != expected_type:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.WARNING,
|
||||
"postbox_target_field_type",
|
||||
(
|
||||
f"Postbox {label} field {field_name!r} should use "
|
||||
f"field type {expected_type.value!r}."
|
||||
),
|
||||
f"{path}/{key}",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _postbox_delivery_issues(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
postbox_available: bool,
|
||||
) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
policies = _delivery_policies(config)
|
||||
if not any(policy.uses_postbox for policy in policies):
|
||||
return issues
|
||||
if not postbox_available:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"postbox_unavailable",
|
||||
(
|
||||
"This campaign uses Postbox delivery, but the Postbox "
|
||||
"module and its delivery directory are not active."
|
||||
),
|
||||
"/delivery/channel_policy",
|
||||
)
|
||||
)
|
||||
for entry_index, entry in enumerate(_active_delivery_entries(config)):
|
||||
policy = effective_delivery_channel_policy(config, entry)
|
||||
if not policy.uses_postbox:
|
||||
continue
|
||||
targets = effective_postbox_targets(config, entry)
|
||||
if not targets:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"postbox_target_missing",
|
||||
"Postbox delivery requires at least one target.",
|
||||
f"/entries/inline/{entry_index}/postbox_targets",
|
||||
)
|
||||
)
|
||||
continue
|
||||
seen_ids: set[str] = set()
|
||||
for target_index, target in enumerate(targets):
|
||||
target_path = (
|
||||
f"/entries/inline/{entry_index}/postbox_targets/"
|
||||
f"{target_index}"
|
||||
)
|
||||
if target.id in seen_ids:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.WARNING,
|
||||
"postbox_target_id_duplicate",
|
||||
f"Postbox target id {target.id!r} is repeated.",
|
||||
f"{target_path}/id",
|
||||
)
|
||||
)
|
||||
seen_ids.add(target.id)
|
||||
issues.extend(
|
||||
_postbox_target_field_issues(config, target, target_path)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _delivery_issues(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
postbox_available: bool,
|
||||
) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
policies = _delivery_policies(config)
|
||||
uses_mail = any(policy.uses_mail for policy in policies)
|
||||
profile_id = (config.server.mail_profile_id or "").strip()
|
||||
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id:
|
||||
if (
|
||||
(
|
||||
config.campaign.mode == "send"
|
||||
and uses_mail
|
||||
or config.delivery.imap_append_sent.enabled
|
||||
)
|
||||
and not profile_id
|
||||
):
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
@@ -350,7 +504,12 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
)
|
||||
)
|
||||
capabilities = config.server.profile_capabilities
|
||||
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available:
|
||||
if (
|
||||
config.campaign.mode == "send"
|
||||
and uses_mail
|
||||
and profile_id
|
||||
and not capabilities.smtp_available
|
||||
):
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
@@ -368,13 +527,22 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
"/server/mail_profile_id",
|
||||
)
|
||||
)
|
||||
issues.extend(
|
||||
_postbox_delivery_issues(
|
||||
config,
|
||||
postbox_available=postbox_available,
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
"""Require Campaign-owned sender data before a send-mode build."""
|
||||
|
||||
if config.campaign.mode != "send":
|
||||
if (
|
||||
config.campaign.mode != "send"
|
||||
or not any(policy.uses_mail for policy in _delivery_policies(config))
|
||||
):
|
||||
return []
|
||||
if config.entries.is_inline:
|
||||
return [
|
||||
@@ -385,7 +553,11 @@ def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
f"/entries/inline/{index}/from",
|
||||
)
|
||||
for index, entry in enumerate(config.entries.inline or [])
|
||||
if entry.active and not effective_address_lists(config, entry)["from"]
|
||||
if (
|
||||
entry.active
|
||||
and effective_delivery_channel_policy(config, entry).uses_mail
|
||||
and not effective_address_lists(config, entry)["from"]
|
||||
)
|
||||
]
|
||||
if config.recipients.from_:
|
||||
return []
|
||||
@@ -611,6 +783,7 @@ def validate_campaign_config(
|
||||
*,
|
||||
campaign_file: str | Path | None = None,
|
||||
check_files: bool = False,
|
||||
postbox_available: bool = False,
|
||||
) -> SemanticReport:
|
||||
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
|
||||
issues: list[SemanticIssue] = []
|
||||
@@ -622,7 +795,12 @@ def validate_campaign_config(
|
||||
issues.extend(_global_value_issues(config, declared_names))
|
||||
issues.extend(_attachment_path_issues(config))
|
||||
issues.extend(_zip_configuration_issues(config))
|
||||
issues.extend(_delivery_issues(config))
|
||||
issues.extend(
|
||||
_delivery_issues(
|
||||
config,
|
||||
postbox_available=postbox_available,
|
||||
)
|
||||
)
|
||||
issues.extend(_sender_issues(config))
|
||||
|
||||
entries = _entries_validation(
|
||||
|
||||
Reference in New Issue
Block a user