first version able to send
This commit is contained in:
@@ -136,6 +136,14 @@ class RecipientConfig(StrictModel):
|
||||
|
||||
class RecipientsConfig(StrictModel):
|
||||
from_: RecipientConfig | None = Field(default=None, alias="from")
|
||||
|
||||
@field_validator("from_", mode="before")
|
||||
@classmethod
|
||||
def empty_from_object_means_unset(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict) and not any(value.values()):
|
||||
return None
|
||||
return value
|
||||
|
||||
allow_individual_from: bool = False
|
||||
|
||||
to: list[RecipientConfig] = Field(default_factory=list)
|
||||
@@ -200,6 +208,17 @@ class ZipConfig(StrictModel):
|
||||
method: ZipMethod = ZipMethod.AES
|
||||
|
||||
|
||||
class AttachmentBasePathConfig(StrictModel):
|
||||
id: str | None = None
|
||||
name: str
|
||||
path: str = "."
|
||||
allow_individual: bool = False
|
||||
# Legacy UI builds briefly wrote a source value. Keep accepting it so older
|
||||
# drafts do not become invalid merely because the current UI no longer shows
|
||||
# or edits that column.
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class AttachmentConfig(StrictModel):
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
@@ -208,26 +227,45 @@ class AttachmentConfig(StrictModel):
|
||||
include_subdirs: bool = False
|
||||
required: bool = True
|
||||
allow_multiple: bool = False
|
||||
missing_behavior: Behavior = Behavior.ASK
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
# None means: inherit from validation_policy. Explicit values remain
|
||||
# supported for backwards compatibility and per-rule overrides.
|
||||
missing_behavior: Behavior | None = None
|
||||
ambiguous_behavior: Behavior | None = None
|
||||
zip: ZipConfig = Field(default_factory=ZipConfig)
|
||||
|
||||
|
||||
class AttachmentsConfig(StrictModel):
|
||||
base_path: str = "."
|
||||
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
|
||||
allow_individual: bool = False
|
||||
send_without_attachments: bool = True
|
||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||
missing_behavior: Behavior = Behavior.ASK
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
|
||||
@property
|
||||
def individual_base_path_values(self) -> set[str]:
|
||||
return {base_path.path for base_path in self.base_paths if base_path.allow_individual}
|
||||
|
||||
|
||||
class EntryConfig(StrictModel):
|
||||
id: str | None = None
|
||||
active: bool = True
|
||||
# Compatibility fields written by older/current WebUI recipient rows.
|
||||
# Address routing uses the explicit to/cc/bcc/reply_to/from fields below;
|
||||
# these values are retained for round-tripping but are not used for sending.
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
from_: RecipientConfig | None = Field(default=None, alias="from")
|
||||
|
||||
@field_validator("from_", mode="before")
|
||||
@classmethod
|
||||
def empty_from_object_means_unset(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict) and not any(value.values()):
|
||||
return None
|
||||
return value
|
||||
|
||||
to: list[RecipientConfig] = Field(default_factory=list)
|
||||
combine_to: bool = True
|
||||
|
||||
@@ -270,8 +308,12 @@ class EntriesConfig(StrictModel):
|
||||
@model_validator(mode="after")
|
||||
def inline_or_external(self) -> "EntriesConfig":
|
||||
has_inline = self.inline is not None
|
||||
has_external = self.source is not None or self.mapping is not None or self.defaults is not None
|
||||
if has_inline and has_external:
|
||||
has_external_source = self.source is not None or self.mapping is not None
|
||||
# defaults are compatible with both inline and external entries. The
|
||||
# WebUI stores the current per-entry combination defaults here even for
|
||||
# inline campaigns, so treating defaults as an external-source marker
|
||||
# made valid UI drafts fail backend validation.
|
||||
if has_inline and has_external_source:
|
||||
raise ValueError("entries must be either inline or source-based, not both")
|
||||
if has_inline:
|
||||
return self
|
||||
@@ -292,6 +334,7 @@ class ValidationPolicy(StrictModel):
|
||||
missing_required_attachment: Behavior = Behavior.ASK
|
||||
missing_optional_attachment: Behavior = Behavior.WARN
|
||||
ambiguous_attachment_match: Behavior = Behavior.ASK
|
||||
ignore_empty_fields: bool = False
|
||||
missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK
|
||||
template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK
|
||||
inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Iterable
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .field_values import ignored_entry_field_overrides
|
||||
from .models import CampaignConfig, EntryConfig, SourceType
|
||||
from .models import AttachmentConfig, CampaignConfig, EntryConfig, SourceType
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
@@ -136,6 +136,59 @@ def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, s
|
||||
return paths
|
||||
|
||||
|
||||
def _attachment_base_path_report_value(config: CampaignConfig) -> str:
|
||||
if config.attachments.base_paths:
|
||||
return ", ".join(f"{base_path.name}: {base_path.path}" for base_path in config.attachments.base_paths)
|
||||
return config.attachments.base_path
|
||||
|
||||
|
||||
def _iter_attachment_rules(config: CampaignConfig) -> Iterable[tuple[str, AttachmentConfig, bool]]:
|
||||
for index, attachment_config in enumerate(config.attachments.global_):
|
||||
yield f"/attachments/global/{index}", attachment_config, False
|
||||
|
||||
inline_entries = config.entries.inline or [] if config.entries.is_inline else []
|
||||
for entry_index, entry in enumerate(inline_entries):
|
||||
for attachment_index, attachment_config in enumerate(entry.attachments):
|
||||
yield f"/entries/inline/{entry_index}/attachments/{attachment_index}", attachment_config, True
|
||||
|
||||
if config.entries.defaults:
|
||||
for attachment_index, attachment_config in enumerate(config.entries.defaults.attachments):
|
||||
yield f"/entries/defaults/attachments/{attachment_index}", attachment_config, True
|
||||
|
||||
|
||||
def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
configured_paths = {base_path.path for base_path in config.attachments.base_paths}
|
||||
individual_paths = config.attachments.individual_base_path_values
|
||||
|
||||
if config.attachments.base_paths:
|
||||
for index, base_path in enumerate(config.attachments.base_paths):
|
||||
if not base_path.name.strip():
|
||||
issues.append(_issue(Severity.WARNING, "attachment_base_path_missing_name", "attachment base path has no display name", f"/attachments/base_paths/{index}/name"))
|
||||
if not base_path.path.strip():
|
||||
issues.append(_issue(Severity.ERROR, "attachment_base_path_missing_path", "attachment base path has no path", f"/attachments/base_paths/{index}/path"))
|
||||
elif not config.attachments.base_path:
|
||||
issues.append(_issue(Severity.INFO, "missing_attachment_base_path", "Attachment base path is not configured yet.", "/attachments/base_path"))
|
||||
|
||||
if configured_paths:
|
||||
for path, attachment_config, is_individual in _iter_attachment_rules(config):
|
||||
if attachment_config.base_dir and attachment_config.base_dir not in configured_paths:
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"unknown_attachment_base_path",
|
||||
f"attachment rule refers to base path {attachment_config.base_dir!r}, but it is not listed in attachments.base_paths",
|
||||
f"{path}/base_dir",
|
||||
))
|
||||
if is_individual and individual_paths and attachment_config.base_dir not in individual_paths:
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"individual_attachment_base_path_not_allowed",
|
||||
f"individual attachment rule uses base path {attachment_config.base_dir!r}, but that base path does not allow individual attachments",
|
||||
f"{path}/base_dir",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
|
||||
return [
|
||||
_issue(
|
||||
@@ -170,6 +223,8 @@ def validate_campaign_config(
|
||||
f"/global_values/{key}",
|
||||
))
|
||||
|
||||
issues.extend(_attachment_path_issues(config))
|
||||
|
||||
if config.server.imap and config.server.imap.enabled:
|
||||
missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")]
|
||||
if missing:
|
||||
@@ -263,14 +318,25 @@ def validate_campaign_config(
|
||||
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
|
||||
|
||||
if check_files:
|
||||
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
|
||||
if not attachments_base_path.exists():
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"attachments_base_path_not_found",
|
||||
f"attachments.base_path does not exist: {attachments_base_path}",
|
||||
"/attachments/base_path",
|
||||
))
|
||||
if config.attachments.base_paths:
|
||||
for index, base_path_config in enumerate(config.attachments.base_paths):
|
||||
attachments_base_path = _resolve(campaign_path, base_path_config.path)
|
||||
if not attachments_base_path.exists():
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"attachments_base_path_not_found",
|
||||
f"attachment base path {base_path_config.name!r} does not exist: {attachments_base_path}",
|
||||
f"/attachments/base_paths/{index}/path",
|
||||
))
|
||||
else:
|
||||
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
|
||||
if not attachments_base_path.exists():
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"attachments_base_path_not_found",
|
||||
f"attachments.base_path does not exist: {attachments_base_path}",
|
||||
"/attachments/base_path",
|
||||
))
|
||||
for schema_path, raw_path in _iter_template_source_paths(config):
|
||||
path = _resolve(campaign_path, raw_path)
|
||||
if not path.exists():
|
||||
@@ -287,7 +353,7 @@ def validate_campaign_config(
|
||||
issues=issues,
|
||||
entries_mode=entries_mode,
|
||||
entries_count=entries_count,
|
||||
attachments_base_path=config.attachments.base_path,
|
||||
attachments_base_path=_attachment_base_path_report_value(config),
|
||||
rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}",
|
||||
imap_append_enabled=config.delivery.imap_append_sent.enabled,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user