initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions

1
.gitignore vendored
View File

@@ -326,3 +326,4 @@ cython_debug/
# Built Visual Studio Code Extensions
*.vsix
**/runtime/

View File

@@ -1,2 +1,18 @@
# govoplan-campaign
GovOPlaN Campaign module.
This repository owns the Campaign module manifest, backend router, campaign schemas, campaign ORM models, campaign JSON validation, persistence, reports, message building, mock-send, queue/send control services, and campaign/operator/template/address-book WebUI package. The module currently has hard runtime dependencies on `govoplan-files` and `govoplan-mail` because managed attachments and SMTP/IMAP execution are imported directly.
The remaining `app.*` imports are transitional core adapters for auth, audit, Celery dispatch, and legacy access models.
## Development
Install through the core development environment:
```bash
cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -r requirements-dev.txt
```
The backend module is registered through the `govoplan.modules` entry point `campaigns`. The frontend package is `@govoplan/campaign-webui`.

30
pyproject.toml Normal file
View File

@@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.0"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.0",
"govoplan-files>=0.1.0",
"govoplan-mail>=0.1.0",
"jsonschema>=4,<5",
"pydantic>=2,<3",
"SQLAlchemy>=2,<3",
"pyzipper>=0.3,<1",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_campaign = ["py.typed", "backend/schema/*.json"]
[project.entry-points."govoplan.modules"]
campaigns = "govoplan_campaign.backend.manifest:get_manifest"

View File

@@ -0,0 +1 @@
"""govoplan-campaign module package."""

View File

@@ -0,0 +1 @@
"""Backend integration for this GovOPlaN module."""

View File

@@ -0,0 +1,462 @@
from __future__ import annotations
import fnmatch
import re
from enum import StrEnum
from pathlib import Path
from typing import Any, Iterable
from pydantic import BaseModel, ConfigDict, Field
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig, ZipArchiveConfig, ZipRuleMode
class AttachmentScope(StrEnum):
GLOBAL = "global"
ENTRY = "entry"
class AttachmentMatchStatus(StrEnum):
OK = "ok"
MISSING = "missing"
AMBIGUOUS = "ambiguous"
class MessageAttachmentStatus(StrEnum):
READY = "ready"
WARNING = "warning"
NEEDS_REVIEW = "needs_review"
BLOCKED = "blocked"
EXCLUDED = "excluded"
INACTIVE = "inactive"
class ResolutionSeverity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
class AttachmentIssue(BaseModel):
model_config = ConfigDict(extra="forbid")
severity: ResolutionSeverity
code: str
message: str
behavior: Behavior | None = None
class ResolvedAttachment(BaseModel):
model_config = ConfigDict(extra="forbid")
scope: AttachmentScope
index: int
attachment_id: str | None = None
label: str | None = None
base_dir_template: str
file_filter_template: str
base_path_name: str | None = None
base_path: str | None = None
base_dir: str
file_filter: str
directory: str
include_subdirs: bool
required: bool
allow_multiple: bool
zip_enabled: bool
zip_mode: ZipRuleMode = ZipRuleMode.INHERIT
zip_archive_id: str | None = None
zip_filename: str | None = None
status: AttachmentMatchStatus
behavior: Behavior | None = None
matches: list[str] = Field(default_factory=list)
issues: list[AttachmentIssue] = Field(default_factory=list)
class EntryAttachmentResolution(BaseModel):
model_config = ConfigDict(extra="forbid")
entry_index: int
entry_id: str | None = None
active: bool
status: MessageAttachmentStatus
attachments: list[ResolvedAttachment] = Field(default_factory=list)
issues: list[AttachmentIssue] = Field(default_factory=list)
@property
def match_count(self) -> int:
return sum(len(item.matches) for item in self.attachments)
class AttachmentResolutionReport(BaseModel):
model_config = ConfigDict(extra="forbid")
campaign_id: str
campaign_name: str
campaign_file: str
attachments_base_path: str
entries_count: int
entries: list[EntryAttachmentResolution] = Field(default_factory=list)
@property
def ready_count(self) -> int:
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.READY)
@property
def warning_count(self) -> int:
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.WARNING)
@property
def needs_review_count(self) -> int:
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.NEEDS_REVIEW)
@property
def blocked_count(self) -> int:
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.BLOCKED)
@property
def excluded_count(self) -> int:
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.EXCLUDED)
@property
def inactive_count(self) -> int:
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.INACTIVE)
def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
return (campaign_path.parent / path).resolve()
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
def _render_template(template: str, values: dict[str, Any]) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0)
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
rendered = _render_template(config.base_dir, values).strip()
return rendered or "."
def _base_path_by_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None:
for base_path in config.attachments.base_paths:
if base_path.path == rendered_base_dir:
return base_path
return None
def _base_path_by_id(config: CampaignConfig, base_path_id: str | None) -> AttachmentBasePathConfig | None:
if not base_path_id:
return None
return next((base_path for base_path in config.attachments.base_paths if base_path.id == base_path_id), None)
def _default_base_path(config: CampaignConfig) -> AttachmentBasePathConfig:
return config.attachments.base_paths[0]
def _selected_base_path(
config: CampaignConfig,
attachment_config: AttachmentConfig,
rendered_base_dir: str,
) -> AttachmentBasePathConfig | None:
if not config.attachments.base_paths:
return None
selected_by_id = _base_path_by_id(config, attachment_config.base_path_id)
if selected_by_id is not None:
return selected_by_id
if rendered_base_dir in {"", "."}:
return _default_base_path(config)
return _base_path_by_path(config, rendered_base_dir)
def _rule_allows_multiple(config: AttachmentConfig, rendered_file_filter: str) -> bool:
"""Return whether a rule may produce multiple attachments.
New UI versions no longer expose allow_multiple. Treat wildcard patterns as
inherently multi-match-capable while keeping the legacy allow_multiple flag
for old campaign JSON.
"""
return config.allow_multiple or any(char in rendered_file_filter for char in "*?[")
def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
if config.missing_behavior is not None:
return config.missing_behavior
if config.required:
return campaign_config.validation_policy.missing_required_attachment
return campaign_config.validation_policy.missing_optional_attachment
def _ambiguous_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
return config.ambiguous_behavior or campaign_config.validation_policy.ambiguous_attachment_match
def _attachment_zip_archive(
campaign_config: CampaignConfig,
attachment_config: AttachmentConfig,
) -> ZipArchiveConfig | None:
collection = campaign_config.attachments.zip
if not collection.enabled:
return None
selection = (attachment_config.zip.archive_id or ZipRuleMode.INHERIT.value).strip()
if selection == ZipRuleMode.EXCLUDE.value or attachment_config.zip.mode == ZipRuleMode.EXCLUDE:
return None
if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value}:
selected = collection.archive_by_id(selection)
if selected is not None:
return selected
# Legacy rule-level include/enabled values map to the campaign standard.
if attachment_config.zip.mode == ZipRuleMode.INCLUDE or attachment_config.zip.enabled:
return collection.standard_archive
return collection.standard_archive
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
if archive is None:
return None
rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip"
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
def _entry_attachment_allowed(config: CampaignConfig, attachment_config: AttachmentConfig, values: dict[str, Any]) -> bool:
rendered_base_dir = _rendered_base_dir(attachment_config, values)
selected = _selected_base_path(config, attachment_config, rendered_base_dir)
if config.attachments.base_paths:
return bool(selected and selected.allow_individual)
return config.attachments.allow_individual
def _iter_effective_attachment_configs(
config: CampaignConfig,
entry: EntryConfig,
values: dict[str, Any],
) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]:
if entry.combine_attachments:
for index, attachment_config in enumerate(config.attachments.global_):
yield AttachmentScope.GLOBAL, index, attachment_config
for index, attachment_config in enumerate(entry.attachments):
if _entry_attachment_allowed(config, attachment_config, values):
yield AttachmentScope.ENTRY, index, attachment_config
def _resolve_attachment_directory(
*,
campaign_file: str | Path,
campaign_config: CampaignConfig,
attachment_config: AttachmentConfig,
rendered_base_dir: str,
) -> tuple[Path, AttachmentBasePathConfig | None]:
"""Resolve the directory for an attachment rule.
Legacy campaigns used attachments.base_path as the root and base_dir as a
child directory. Current WebUI campaigns select one named base path directly
in base_dir. Prefer the new base_paths list when present to avoid resolving
e.g. attachments/base_path + base_dir twice.
"""
selected_base_path = _selected_base_path(campaign_config, attachment_config, rendered_base_dir)
if selected_base_path is not None:
return _resolve_path(campaign_file, selected_base_path.path), selected_base_path
if campaign_config.attachments.base_paths:
return _resolve_path(campaign_file, rendered_base_dir), None
legacy_root = _resolve_path(campaign_file, campaign_config.attachments.base_path)
return (legacy_root / rendered_base_dir).resolve(), None
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
if include_subdirs:
# pathlib.rglob accepts glob patterns, but fnmatch keeps behavior predictable
# when file_filter is supplied as the Java-style filter portion only.
return sorted(path for path in directory.rglob("*") if path.is_file() and fnmatch.fnmatch(path.name, file_filter))
return sorted(path for path in directory.glob(file_filter) if path.is_file())
def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue:
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
return AttachmentIssue(
severity=severity,
code=code,
message=f"No file matched attachment filter {config.file_filter!r}",
behavior=behavior,
)
def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_count: int) -> AttachmentIssue:
severity = ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
return AttachmentIssue(
severity=severity,
code="ambiguous_attachment_match",
message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but it is configured as a direct/single-file selection",
behavior=behavior,
)
def _resolve_one_config(
*,
campaign_file: str | Path,
campaign_config: CampaignConfig,
values: dict[str, Any],
scope: AttachmentScope,
index: int,
config: AttachmentConfig,
) -> ResolvedAttachment:
rendered_base_dir = _rendered_base_dir(config, values)
rendered_file_filter = _render_template(config.file_filter, values)
directory, selected_base_path = _resolve_attachment_directory(
campaign_file=campaign_file,
campaign_config=campaign_config,
attachment_config=config,
rendered_base_dir=rendered_base_dir,
)
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
issues: list[AttachmentIssue] = []
behavior: Behavior | None = None
if not matches:
status = AttachmentMatchStatus.MISSING
behavior = _missing_behavior(campaign_config, config)
issues.append(_issue_for_missing(config, behavior))
elif len(matches) > 1 and not allow_multiple:
status = AttachmentMatchStatus.AMBIGUOUS
behavior = _ambiguous_behavior(campaign_config, config)
issues.append(_issue_for_ambiguous(config, behavior, len(matches)))
else:
status = AttachmentMatchStatus.OK
archive = _attachment_zip_archive(campaign_config, config)
return ResolvedAttachment(
scope=scope,
index=index,
attachment_id=config.id,
label=config.label,
base_dir_template=config.base_dir,
file_filter_template=config.file_filter,
base_path_name=selected_base_path.name if selected_base_path else None,
base_path=selected_base_path.path if selected_base_path else None,
base_dir=rendered_base_dir,
file_filter=rendered_file_filter,
directory=str(directory),
include_subdirs=config.include_subdirs,
required=config.required,
allow_multiple=allow_multiple,
zip_enabled=archive is not None,
zip_mode=config.zip.mode,
zip_archive_id=archive.id if archive else None,
zip_filename=_render_zip_filename(archive, values),
status=status,
behavior=behavior,
matches=[str(path) for path in matches],
issues=issues,
)
def _status_from_issues(active: bool, issues: list[AttachmentIssue]) -> MessageAttachmentStatus:
if not active:
return MessageAttachmentStatus.INACTIVE
behaviors = {issue.behavior for issue in issues if issue.behavior is not None}
if Behavior.BLOCK in behaviors:
return MessageAttachmentStatus.BLOCKED
if Behavior.DROP in behaviors:
return MessageAttachmentStatus.EXCLUDED
if Behavior.ASK in behaviors:
return MessageAttachmentStatus.NEEDS_REVIEW
if Behavior.WARN in behaviors:
return MessageAttachmentStatus.WARNING
return MessageAttachmentStatus.READY
def resolve_entry_attachments(
*,
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
) -> EntryAttachmentResolution:
values = build_template_values(config, entry)
resolved: list[ResolvedAttachment] = []
if entry.active:
for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry, values):
resolved.append(
_resolve_one_config(
campaign_file=campaign_file,
campaign_config=config,
values=values,
scope=scope,
index=index,
config=attachment_config,
)
)
issues = [issue for item in resolved for issue in item.issues]
return EntryAttachmentResolution(
entry_index=entry_index,
entry_id=entry.id,
active=entry.active,
status=_status_from_issues(entry.active, issues),
attachments=resolved,
issues=issues,
)
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
entries = load_campaign_entries(config, campaign_file=campaign_file)
base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path)
resolved_entries = [
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index)
for index, entry in enumerate(entries, start=1)
]
return AttachmentResolutionReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
campaign_file=str(Path(campaign_file).resolve()),
attachments_base_path=str(base_path),
entries_count=len(entries),
entries=resolved_entries,
)

View File

@@ -0,0 +1,14 @@
"""Campaign JSON model, loading and validation helpers."""
from .models import CampaignConfig
from .loader import load_campaign_config, load_campaign_json
from .validation import validate_campaign_config, SemanticIssue, SemanticReport
__all__ = [
"CampaignConfig",
"load_campaign_config",
"load_campaign_json",
"validate_campaign_config",
"SemanticIssue",
"SemanticReport",
]

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
from email.utils import formataddr
from typing import Iterable
from .models import CampaignConfig, EntryConfig, RecipientConfig
ADDRESS_TEMPLATE_FIELDS = ("from", "to", "reply_to", "cc", "bcc")
def _deduplicate(recipients: Iterable[RecipientConfig]) -> list[RecipientConfig]:
unique: list[RecipientConfig] = []
seen: set[str] = set()
for recipient in recipients:
key = recipient.email.strip().casefold()
if not key or key in seen:
continue
seen.add(key)
unique.append(recipient)
return unique
def _effective_list(
*,
global_recipients: list[RecipientConfig],
local_recipients: list[RecipientConfig],
allow_individual: bool,
merge: bool,
) -> list[RecipientConfig]:
if not allow_individual:
return _deduplicate(global_recipients)
if not local_recipients:
return _deduplicate(global_recipients)
if merge:
return _deduplicate([*global_recipients, *local_recipients])
return _deduplicate(local_recipients)
def _effective_single(
*,
global_recipients: list[RecipientConfig],
local_recipients: list[RecipientConfig],
allow_individual: bool,
) -> list[RecipientConfig]:
"""Resolve a single-mailbox header such as From.
A recipient-specific value overrides the campaign value when allowed. Any
additional legacy values are ignored deliberately; the canonical schema and
WebUI prevent creating them.
"""
candidates = local_recipients if allow_individual and local_recipients else global_recipients
unique = _deduplicate(candidates)
return unique[:1]
def effective_address_lists(config: CampaignConfig, entry: EntryConfig) -> dict[str, list[RecipientConfig]]:
"""Return the exact address lists used by a built message.
``merge_*`` is the canonical configuration. Older ``combine_*`` values are
normalized by EntryConfig before this function is called.
"""
return {
"from": _effective_single(
global_recipients=config.recipients.from_,
local_recipients=entry.from_,
allow_individual=config.recipients.allow_individual_from,
),
"to": _effective_list(
global_recipients=config.recipients.to,
local_recipients=entry.to,
allow_individual=config.recipients.allow_individual_to,
merge=entry.merge_to,
),
"cc": _effective_list(
global_recipients=config.recipients.cc,
local_recipients=entry.cc,
allow_individual=config.recipients.allow_individual_cc,
merge=entry.merge_cc,
),
"bcc": _effective_list(
global_recipients=config.recipients.bcc,
local_recipients=entry.bcc,
allow_individual=config.recipients.allow_individual_bcc,
merge=entry.merge_bcc,
),
"reply_to": _effective_list(
global_recipients=config.recipients.reply_to,
local_recipients=entry.reply_to,
allow_individual=config.recipients.allow_individual_reply_to,
merge=entry.merge_reply_to,
),
"bounce_to": _effective_list(
global_recipients=config.recipients.bounce_to,
local_recipients=entry.bounce_to,
allow_individual=config.recipients.allow_individual_bounce_to,
merge=entry.merge_bounce_to,
),
"disposition_notification_to": _effective_list(
global_recipients=config.recipients.disposition_notification_to,
local_recipients=entry.disposition_notification_to,
allow_individual=config.recipients.allow_individual_disposition_notification_to,
merge=entry.merge_disposition_notification_to,
),
}
def formatted_recipient(recipient: RecipientConfig) -> str:
return formataddr((recipient.name or "", recipient.email))
def recipient_template_values(addresses: dict[str, list[RecipientConfig]]) -> dict[str, str]:
"""Expose effective address lists as local template values.
Common forms:
local:to first formatted address
local:all_to all formatted addresses, comma separated
local:to.email first address only
local:all_to.email all email addresses, comma separated
local:to[2] second formatted address (one-based)
local:to[2].email second email address
Legacy zero-based keys such as ``local:to.0.email`` remain available.
"""
values: dict[str, str] = {}
for field_name in ADDRESS_TEMPLATE_FIELDS:
recipients = addresses.get(field_name, [])
formatted = [formatted_recipient(recipient) for recipient in recipients]
emails = [recipient.email for recipient in recipients]
names = [recipient.name or "" for recipient in recipients]
first_formatted = formatted[0] if formatted else ""
first_email = emails[0] if emails else ""
first_name = names[0] if names else ""
values[f"local::{field_name}"] = first_formatted
values[f"local::{field_name}.email"] = first_email
values[f"local::{field_name}.name"] = first_name
values[f"local::all_{field_name}"] = ", ".join(formatted)
values[f"local::all_{field_name}.email"] = ", ".join(emails)
values[f"local::all_{field_name}.name"] = ", ".join(name for name in names if name)
for index, recipient in enumerate(recipients):
one_based = index + 1
values[f"local::{field_name}[{one_based}]"] = formatted[index]
values[f"local::{field_name}[{one_based}].email"] = recipient.email
values[f"local::{field_name}[{one_based}].name"] = recipient.name or ""
# Preserve the original zero-based recipient field syntax.
values[f"local::{field_name}.{index}.email"] = recipient.email
values[f"local::{field_name}.{index}.name"] = recipient.name or ""
values[f"local::{field_name}.{index}.type"] = recipient.recipient_type.value
return values

View File

@@ -0,0 +1,222 @@
from __future__ import annotations
import copy
import csv
import json
from pathlib import Path
from typing import Any
from .models import CampaignConfig, EntryConfig, SourceType
class EntryLoadError(ValueError):
"""Raised when campaign entries cannot be loaded from inline or external sources."""
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
return (campaign_path.parent / path).resolve()
def _parse_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
text = str(value).strip().lower()
if text in {"1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"}:
return True
if text in {"0", "false", "no", "n", "nein", "", "inactive", "inaktiv"}:
return False
raise EntryLoadError(f"cannot parse boolean value: {value!r}")
def _parse_scalar_for_target(target: str, value: Any) -> Any:
bool_targets = {
"active",
"merge_from",
"merge_to",
"merge_cc",
"merge_bcc",
"merge_reply_to",
"merge_bounce_to",
"merge_disposition_notification_to",
"combine_to",
"combine_cc",
"combine_bcc",
"combine_reply_to",
"combine_bounce_to",
"combine_disposition_notification_to",
"combine_attachments",
}
if target in bool_targets:
return _parse_bool(value)
if target.endswith(".include_subdirs") or target.endswith(".required") or target.endswith(".allow_multiple"):
return _parse_bool(value)
if target.endswith(".zip.enabled"):
return _parse_bool(value)
return value
def _ensure_list_length(values: list[Any], index: int, factory: Any) -> None:
while len(values) <= index:
values.append(factory())
def _set_recipient_value(entry_data: dict[str, Any], target: str, value: Any) -> bool:
# Examples: from.email, to.0.email, cc.0.name
if target.startswith("from."):
entry_data.setdefault("from", {})
_, field = target.split(".", 1)
if field == "type":
field = "type"
entry_data["from"][field] = value
return True
for recipient_list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]:
prefix = recipient_list_name + "."
if not target.startswith(prefix):
continue
parts = target.split(".")
if len(parts) != 3 or not parts[1].isdigit():
raise EntryLoadError(f"invalid recipient mapping target: {target}")
index = int(parts[1])
field = parts[2]
recipients = entry_data.setdefault(recipient_list_name, [])
_ensure_list_length(recipients, index, dict)
recipients[index][field] = value
return True
return False
def _set_attachment_value(entry_data: dict[str, Any], target: str, value: Any) -> bool:
if not target.startswith("attachments."):
return False
parts = target.split(".")
if len(parts) < 3 or not parts[1].isdigit():
raise EntryLoadError(f"invalid attachment mapping target: {target}")
index = int(parts[1])
attachments = entry_data.setdefault("attachments", [])
_ensure_list_length(attachments, index, dict)
attachment = attachments[index]
if parts[2] == "zip":
if len(parts) != 4:
raise EntryLoadError(f"invalid zip attachment mapping target: {target}")
attachment.setdefault("zip", {})[parts[3]] = value
return True
if len(parts) != 3:
raise EntryLoadError(f"invalid attachment mapping target: {target}")
attachment[parts[2]] = value
return True
def _set_entry_value(entry_data: dict[str, Any], target: str, value: Any) -> None:
value = _parse_scalar_for_target(target, value)
if value is None:
return
if isinstance(value, str) and value == "":
return
if target.startswith("fields."):
_, field_name = target.split(".", 1)
entry_data.setdefault("fields", {})[field_name] = value
return
if _set_recipient_value(entry_data, target, value):
return
if _set_attachment_value(entry_data, target, value):
return
entry_data[target] = value
def _entry_defaults_data(config: CampaignConfig) -> dict[str, Any]:
if config.entries.defaults is None:
return {}
return config.entries.defaults.model_dump(mode="json", by_alias=True, exclude_none=True)
def _load_csv_rows(path: Path, *, delimiter: str, encoding: str) -> list[dict[str, Any]]:
try:
with path.open("r", encoding=encoding, newline="") as handle:
reader = csv.DictReader(handle, delimiter=delimiter)
return [dict(row) for row in reader]
except OSError as exc:
raise EntryLoadError(f"could not read CSV entries source {path}: {exc}") from exc
def _load_json_rows(path: Path, *, encoding: str) -> list[dict[str, Any]]:
try:
with path.open("r", encoding=encoding) as handle:
data = json.load(handle)
except OSError as exc:
raise EntryLoadError(f"could not read JSON entries source {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise EntryLoadError(f"invalid JSON entries source {path}: {exc}") from exc
if isinstance(data, list):
rows = data
elif isinstance(data, dict) and isinstance(data.get("entries"), list):
rows = data["entries"]
else:
raise EntryLoadError("JSON entries source must be a list or an object with an 'entries' list")
if not all(isinstance(row, dict) for row in rows):
raise EntryLoadError("JSON entries source rows must be objects")
return [dict(row) for row in rows]
def _row_to_entry(defaults_data: dict[str, Any], mapping: dict[str, str], row: dict[str, Any], row_number: int) -> EntryConfig:
entry_data = copy.deepcopy(defaults_data)
for target, source_name in mapping.items():
if source_name not in row:
# Detailed missing-column validation is handled in semantic validation.
continue
try:
_set_entry_value(entry_data, target, row[source_name])
except EntryLoadError:
raise
except Exception as exc:
raise EntryLoadError(f"row {row_number}: could not map {source_name!r} to {target!r}: {exc}") from exc
try:
return EntryConfig.model_validate(entry_data)
except Exception as exc:
raise EntryLoadError(f"row {row_number}: mapped entry is invalid: {exc}") from exc
def load_campaign_entries(config: CampaignConfig, *, campaign_file: str | Path) -> list[EntryConfig]:
"""Load and normalize campaign entries from inline data or external CSV/JSON source.
The normalized output is always a list of EntryConfig instances. This is intentionally
UI/API friendly: a future web interface can generate the same JSON structure and use the
same resolver without code changes.
"""
if config.entries.inline is not None:
return list(config.entries.inline)
if config.entries.source is None or config.entries.mapping is None:
raise EntryLoadError("external entries require source and mapping")
source = config.entries.source
path = _resolve(campaign_file, source.path)
if not path.exists():
raise EntryLoadError(f"entries source file does not exist: {path}")
if source.type == SourceType.CSV:
if not source.has_header:
raise EntryLoadError("CSV entries currently require has_header=true")
rows = _load_csv_rows(path, delimiter=source.delimiter, encoding=source.encoding)
elif source.type == SourceType.JSON:
rows = _load_json_rows(path, encoding=source.encoding)
else: # pragma: no cover - defensive; Pydantic constrains this already.
raise EntryLoadError(f"unsupported entries source type: {source.type}")
defaults_data = _entry_defaults_data(config)
return [_row_to_entry(defaults_data, config.entries.mapping, row, index + 2) for index, row in enumerate(rows)]

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from .models import CampaignConfig, EntryConfig, FieldDefinition
def field_definitions_by_name(config: CampaignConfig) -> dict[str, FieldDefinition]:
"""Return campaign field definitions keyed by field id/name."""
return {field.name: field for field in config.fields}
def field_can_override(config: CampaignConfig, field_name: str) -> bool:
"""Return whether a recipient/entry value may override the global value.
Unknown fields remain overridable for backwards compatibility with older
campaigns and ad-hoc external mappings. Semantic validation reports unknown
field usage separately when a field list is configured.
"""
field = field_definitions_by_name(config).get(field_name)
if field is None:
return True
return field.can_override
def ignored_entry_field_overrides(config: CampaignConfig, entry: EntryConfig) -> list[str]:
"""Return recipient field keys that are ignored by the override policy."""
return sorted(name for name in entry.fields if not field_can_override(config, name))
def effective_entry_field_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
"""Return the local/effective field value map for one message entry.
Global values act as defaults for local template placeholders. Recipient
values replace those defaults only when the corresponding field allows
overrides. Fields that are unknown to the campaign definition keep the old
permissive behavior and remain usable as local values.
"""
values: dict[str, Any] = dict(config.global_values)
for key, value in entry.fields.items():
if field_can_override(config, key) and entry_field_has_override_value(value):
values[key] = value
return values
def entry_field_has_override_value(value: Any) -> bool:
"""Return whether an entry field should override a global default.
Empty recipient values are treated as "not set" so global_values remain the
effective local defaults. Numeric zero and boolean false are valid explicit
overrides.
"""
if value is None:
return False
if isinstance(value, str):
return value.strip() != ""
return True

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator, FormatChecker
from .models import CampaignConfig
class CampaignLoadError(ValueError):
"""Raised when the campaign JSON cannot be loaded or parsed."""
@dataclass(frozen=True)
class SchemaValidationError:
path: str
message: str
class CampaignSchemaError(CampaignLoadError):
def __init__(self, errors: list[SchemaValidationError]) -> None:
self.errors = errors
details = "; ".join(f"{error.path}: {error.message}" for error in errors[:5])
if len(errors) > 5:
details += f"; ... and {len(errors) - 5} more"
super().__init__(f"campaign schema validation failed: {details}")
def load_campaign_json(path: str | Path) -> dict[str, Any]:
campaign_path = Path(path)
try:
with campaign_path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except OSError as exc:
raise CampaignLoadError(f"could not read campaign JSON {campaign_path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise CampaignLoadError(f"invalid campaign JSON {campaign_path}: {exc}") from exc
if not isinstance(data, dict):
raise CampaignLoadError("campaign JSON root must be an object")
return data
def _default_schema_path() -> Path:
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json"
def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]:
path = Path(schema_path) if schema_path else _default_schema_path()
return load_campaign_json(path)
def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None:
schema = load_campaign_schema(schema_path)
validator = Draft202012Validator(schema, format_checker=FormatChecker())
errors = sorted(validator.iter_errors(data), key=lambda error: list(error.path))
if errors:
normalized = [
SchemaValidationError(
path="/" + "/".join(str(part) for part in error.absolute_path),
message=error.message,
)
for error in errors
]
raise CampaignSchemaError(normalized)
def load_campaign_config(
path: str | Path,
*,
validate_schema: bool = True,
schema_path: str | Path | None = None,
) -> CampaignConfig:
data = load_campaign_json(path)
if validate_schema:
validate_against_schema(data, schema_path=schema_path)
return CampaignConfig.model_validate(data)

View File

@@ -0,0 +1,551 @@
from __future__ import annotations
from enum import StrEnum
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
class CampaignMode(StrEnum):
DRAFT = "draft"
TEST = "test"
SEND = "send"
class FieldType(StrEnum):
STRING = "string"
INTEGER = "integer"
DOUBLE = "double"
DATE = "date"
PASSWORD = "password"
class RecipientType(StrEnum):
TO = "to"
CC = "cc"
BCC = "bcc"
REPLY_TO = "reply_to"
BOUNCE_TO = "bounce_to"
DISPOSITION_NOTIFICATION_TO = "disposition_notification_to"
class Behavior(StrEnum):
BLOCK = "block"
ASK = "ask"
DROP = "drop"
CONTINUE = "continue"
WARN = "warn"
class MissingAddressBehavior(StrEnum):
BLOCK = "block"
DROP = "drop"
class InactiveEntryBehavior(StrEnum):
DROP = "drop"
BLOCK = "block"
WARN = "warn"
class SourceType(StrEnum):
CSV = "csv"
JSON = "json"
class ZipMethod(StrEnum):
ZIP_STANDARD = "zip_standard"
AES = "aes"
class ZipRuleMode(StrEnum):
INHERIT = "inherit"
INCLUDE = "include"
EXCLUDE = "exclude"
class ZipPasswordScope(StrEnum):
LOCAL = "local"
GLOBAL = "global"
class ZipPasswordMode(StrEnum):
NONE = "none"
DIRECT = "direct"
FIELD = "field"
TEMPLATE = "template"
class BuildStatus(StrEnum):
BUILT = "built"
BUILD_FAILED = "build_failed"
class SendStatus(StrEnum):
DRAFT = "draft"
QUEUED = "queued"
class CampaignMeta(StrictModel):
id: str
name: str
description: str | None = None
mode: CampaignMode = CampaignMode.DRAFT
class FieldDefinition(StrictModel):
name: str
type: FieldType = FieldType.STRING
label: str | None = None
required: bool = False
can_override: bool = True
class ServerConfig(StrictModel):
mail_profile_id: str | None = None
inherit_smtp_credentials: bool = True
inherit_imap_credentials: bool = True
smtp: SmtpConfig | None = None
imap: ImapConfig | None = None
class RecipientConfig(StrictModel):
email: str
name: str | None = None
recipient_type: RecipientType = Field(default=RecipientType.TO, alias="type")
@field_validator("email")
@classmethod
def email_should_look_like_address(cls, value: str) -> str:
# JSON Schema's format=email remains the stricter validation layer.
# Keep this deliberately lightweight to avoid an extra email-validator dependency.
if "@" not in value:
raise ValueError("email must contain '@'")
return value
class RecipientsConfig(StrictModel):
from_: list[RecipientConfig] = Field(default_factory=list, alias="from", max_length=1)
@field_validator("from_", mode="before")
@classmethod
def normalize_from_list(cls, value: Any) -> Any:
# Older campaign files stored From as one object. The canonical model
# is now an array so every address header has the same list semantics.
if value is None:
return []
if isinstance(value, dict):
return [] if not any(value.values()) else [value]
return value
allow_individual_from: bool = False
to: list[RecipientConfig] = Field(default_factory=list)
allow_individual_to: bool = False
cc: list[RecipientConfig] = Field(default_factory=list)
allow_individual_cc: bool = False
bcc: list[RecipientConfig] = Field(default_factory=list)
allow_individual_bcc: bool = False
reply_to: list[RecipientConfig] = Field(default_factory=list)
allow_individual_reply_to: bool = False
bounce_to: list[RecipientConfig] = Field(default_factory=list)
allow_individual_bounce_to: bool = False
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
allow_individual_disposition_notification_to: bool = False
class TemplateSourceConfig(StrictModel):
type: Literal["files"] = "files"
subject_path: str | None = None
text_path: str | None = None
html_path: str | None = None
encoding: str = "utf-8"
@model_validator(mode="after")
def at_least_one_path(self) -> "TemplateSourceConfig":
if not any([self.subject_path, self.text_path, self.html_path]):
raise ValueError("template.source must define subject_path, text_path or html_path")
return self
class TemplateConfig(StrictModel):
subject: str | None = None
text: str | None = None
html: str | None = None
source: TemplateSourceConfig | None = None
@model_validator(mode="after")
def inline_or_source(self) -> "TemplateConfig":
inline_values = any(value is not None for value in [self.subject, self.text, self.html])
if self.source and inline_values:
raise ValueError("template must be either inline or source-based, not both")
if self.source:
return self
if not self.subject:
raise ValueError("inline template requires subject")
return self
@property
def is_external(self) -> bool:
return self.source is not None
class ZipArchiveConfig(StrictModel):
id: str
name: str = "attachments.zip"
standard: bool = False
password_enabled: bool = False
password_field: str | None = None
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
method: ZipMethod = ZipMethod.AES
# Compatibility fields for campaigns created by the first single-archive
# implementation. New WebUI campaigns use password_enabled/field/scope.
password_mode: ZipPasswordMode | None = None
password: str | None = None
password_template: str | None = None
@model_validator(mode="before")
@classmethod
def normalize_legacy_archive(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
if "name" not in normalized and normalized.get("filename_template"):
normalized["name"] = normalized.get("filename_template")
normalized.pop("filename_template", None)
mode = normalized.get("password_mode")
if "password_enabled" not in normalized and mode in {
ZipPasswordMode.DIRECT.value,
ZipPasswordMode.FIELD.value,
ZipPasswordMode.TEMPLATE.value,
}:
normalized["password_enabled"] = True
if mode == ZipPasswordMode.FIELD.value and "password_scope" not in normalized:
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
return normalized
class ZipCollectionConfig(StrictModel):
enabled: bool = False
archives: list[ZipArchiveConfig] = Field(default_factory=list)
@model_validator(mode="before")
@classmethod
def normalize_single_archive_config(cls, value: Any) -> Any:
"""Upgrade the original single-recipient-ZIP object in memory.
The campaign JSON remains portable: old filename/password settings are
converted to one standard archive, while the new format is passed
through unchanged.
"""
if not isinstance(value, dict) or "archives" in value:
return value
legacy = dict(value)
enabled = bool(legacy.get("enabled"))
meaningful = enabled or any(
legacy.get(key) not in (None, "", False, "none", "inherit")
for key in ("filename_template", "password_mode", "password", "password_field", "password_template")
)
if not meaningful:
return {"enabled": False, "archives": []}
archive = {
"id": "default",
"name": legacy.get("filename_template") or "attachments.zip",
"standard": True,
"method": legacy.get("method", ZipMethod.AES.value),
"password_mode": legacy.get("password_mode"),
"password": legacy.get("password"),
"password_field": legacy.get("password_field"),
"password_template": legacy.get("password_template"),
}
mode = legacy.get("password_mode")
if not mode and legacy.get("password_template"):
mode = ZipPasswordMode.TEMPLATE.value
archive["password_mode"] = mode
archive["password_enabled"] = mode in {
ZipPasswordMode.DIRECT.value,
ZipPasswordMode.FIELD.value,
ZipPasswordMode.TEMPLATE.value,
}
archive["password_scope"] = ZipPasswordScope.LOCAL.value
return {"enabled": enabled, "archives": [archive]}
@property
def standard_archive(self) -> ZipArchiveConfig | None:
return next((archive for archive in self.archives if archive.standard), self.archives[0] if self.archives else None)
def archive_by_id(self, archive_id: str | None) -> ZipArchiveConfig | None:
if not archive_id:
return None
return next((archive for archive in self.archives if archive.id == archive_id), None)
class ZipRuleConfig(StrictModel):
# New format: inherit the campaign standard, select an archive id, or use
# the reserved value "exclude" to send the file outside every archive.
archive_id: str = ZipRuleMode.INHERIT.value
# Original per-rule fields remain accepted for imported campaign JSON.
enabled: bool = False
mode: ZipRuleMode = ZipRuleMode.INHERIT
filename_template: str | None = None
password_mode: ZipPasswordMode = ZipPasswordMode.NONE
password: str | None = None
password_field: str | None = None
password_template: str | None = None
method: ZipMethod = ZipMethod.AES
@model_validator(mode="before")
@classmethod
def normalize_legacy_password_mode(cls, value: Any) -> Any:
if isinstance(value, dict) and value.get("password_template") and "password_mode" not in value:
return {**value, "password_mode": ZipPasswordMode.TEMPLATE.value}
return value
class AttachmentBasePathConfig(StrictModel):
id: str | None = None
name: str
path: str = "."
allow_individual: bool = False
unsent_warning: 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
base_path_id: str | None = None
# Legacy UI helper. Current attachment resolution ignores this value and
# treats direct files as plain file_filter patterns without wildcards.
# Keep accepting it so existing drafts with {"type": ""}, "direct"
# or "pattern" remain valid.
type_: str | None = Field(default=None, alias="type")
base_dir: str
file_filter: str
include_subdirs: bool = False
required: bool = True
allow_multiple: bool = False
@field_validator("type_", mode="before")
@classmethod
def empty_type_means_unset(cls, value: Any) -> Any:
if value == "":
return None
return value
# 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: ZipRuleConfig = Field(default_factory=ZipRuleConfig)
class AttachmentsConfig(StrictModel):
base_path: str = "."
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
allow_individual: bool = False
send_without_attachments: bool = True
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
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
@model_validator(mode="before")
@classmethod
def normalize_legacy_combine_flags(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
for address_field in ("from", "to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"):
merge_key = f"merge_{address_field}"
combine_key = f"combine_{address_field}"
if merge_key not in normalized and combine_key in normalized:
normalized[merge_key] = normalized[combine_key]
normalized.pop(combine_key, None)
return normalized
# 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_: list[RecipientConfig] = Field(default_factory=list, alias="from", max_length=1)
merge_from: bool = False # Deprecated compatibility field; From never merges.
@field_validator("from_", mode="before")
@classmethod
def normalize_from_list(cls, value: Any) -> Any:
if value is None:
return []
if isinstance(value, dict):
return [] if not any(value.values()) else [value]
return value
to: list[RecipientConfig] = Field(default_factory=list)
merge_to: bool = True
cc: list[RecipientConfig] = Field(default_factory=list)
merge_cc: bool = True
bcc: list[RecipientConfig] = Field(default_factory=list)
merge_bcc: bool = True
reply_to: list[RecipientConfig] = Field(default_factory=list)
merge_reply_to: bool = True
bounce_to: list[RecipientConfig] = Field(default_factory=list)
merge_bounce_to: bool = True
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
merge_disposition_notification_to: bool = True
attachments: list[AttachmentConfig] = Field(default_factory=list)
combine_attachments: bool = True
fields: dict[str, Any] = Field(default_factory=dict)
last_sent: str | None = None
class SourceConfig(StrictModel):
type: SourceType
path: str
delimiter: str = ";"
encoding: str = "utf-8"
has_header: bool = True
class EntriesConfig(StrictModel):
inline: list[EntryConfig] | None = None
source: SourceConfig | None = None
mapping: dict[str, str] | None = None
defaults: EntryConfig | None = None
@model_validator(mode="after")
def inline_or_external(self) -> "EntriesConfig":
has_inline = self.inline is not None
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
if self.source is None or self.mapping is None:
raise ValueError("external entries require source and mapping")
return self
@property
def is_inline(self) -> bool:
return self.inline is not None
@property
def is_external(self) -> bool:
return self.source is not None
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
unsent_attachment_files: Behavior = Behavior.WARN
missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK
template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK
inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP
class RateLimitConfig(StrictModel):
messages_per_minute: int = Field(default=5, ge=1)
concurrency: int = Field(default=1, ge=1)
class ImapAppendSentConfig(StrictModel):
enabled: bool = False
folder: str = "auto"
class RetryConfig(StrictModel):
max_attempts: int = Field(default=3, ge=1)
backoff_seconds: list[int] = Field(default_factory=lambda: [60, 300, 900])
@field_validator("backoff_seconds")
@classmethod
def backoff_values_must_be_positive(cls, values: list[int]) -> list[int]:
if any(value < 1 for value in values):
raise ValueError("backoff_seconds values must be >= 1")
return values
class DeliveryConfig(StrictModel):
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
retry: RetryConfig = Field(default_factory=RetryConfig)
class StatusTrackingConfig(StrictModel):
enabled: bool = True
initial_build_status: BuildStatus = BuildStatus.BUILT
initial_send_status: SendStatus = SendStatus.DRAFT
class CampaignConfig(StrictModel):
version: Literal["1.0"]
campaign: CampaignMeta
fields: list[FieldDefinition] = Field(default_factory=list)
global_values: dict[str, Any] = Field(default_factory=dict)
server: ServerConfig = Field(default_factory=ServerConfig)
recipients: RecipientsConfig = Field(default_factory=RecipientsConfig)
template: TemplateConfig
attachments: AttachmentsConfig = Field(default_factory=AttachmentsConfig)
entries: EntriesConfig
validation_policy: ValidationPolicy = Field(default_factory=ValidationPolicy)
delivery: DeliveryConfig = Field(default_factory=DeliveryConfig)
status_tracking: StatusTrackingConfig = Field(default_factory=StatusTrackingConfig)
@model_validator(mode="after")
def field_names_must_be_unique(self) -> "CampaignConfig":
names = [field.name for field in self.fields]
duplicates = sorted({name for name in names if names.count(name) > 1})
if duplicates:
raise ValueError(f"duplicate field definitions: {', '.join(duplicates)}")
return self
@property
def field_names(self) -> set[str]:
return {field.name for field in self.fields}
def resolve_relative_path(self, campaign_file: Path, raw_path: str) -> Path:
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
return (campaign_file.parent / path).resolve()

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
from .addressing import effective_address_lists, recipient_template_values
from .field_values import effective_entry_field_values
from .models import CampaignConfig, EntryConfig
def build_template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
values: dict[str, Any] = {}
for field in config.fields:
values.setdefault(field.name, "")
values.setdefault(f"global::{field.name}", "")
values.setdefault(f"local::{field.name}", "")
for key, value in config.global_values.items():
values[f"global::{key}"] = value
for key, value in effective_entry_field_values(config, entry).items():
values[key] = value
values[f"local::{key}"] = value
if entry.id:
values["local::id"] = entry.id
values["local::active"] = entry.active
values.update(recipient_template_values(effective_address_lists(config, entry)))
return values

View File

@@ -0,0 +1,437 @@
from __future__ import annotations
import csv
from enum import StrEnum
from pathlib import Path
from typing import Iterable
from pydantic import BaseModel, ConfigDict, Field
from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
class Severity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
class SemanticIssue(BaseModel):
model_config = ConfigDict(extra="forbid")
severity: Severity
code: str
message: str
path: str | None = None
class SemanticReport(BaseModel):
model_config = ConfigDict(extra="forbid")
campaign_id: str
campaign_name: str
issues: list[SemanticIssue] = Field(default_factory=list)
entries_mode: str
entries_count: int | None = None
attachments_base_path: str
rate_limit: str
imap_append_enabled: bool
@property
def error_count(self) -> int:
return sum(1 for issue in self.issues if issue.severity == Severity.ERROR)
@property
def warning_count(self) -> int:
return sum(1 for issue in self.issues if issue.severity == Severity.WARNING)
@property
def ok(self) -> bool:
return self.error_count == 0
def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue:
return SemanticIssue(severity=severity, code=code, message=message, path=path)
def _resolve(campaign_file: Path, raw_path: str) -> Path:
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
return (campaign_file.parent / path).resolve()
def _mapping_target_field_name(target: str) -> str | None:
if target.startswith("fields."):
return target.split(".", 1)[1]
return None
def _mapping_target_known(target: str, field_names: set[str]) -> bool:
direct_targets = {
"id",
"active",
"last_sent",
"merge_from",
"merge_to",
"merge_cc",
"merge_bcc",
"merge_reply_to",
"merge_bounce_to",
"merge_disposition_notification_to",
"combine_to",
"combine_cc",
"combine_bcc",
"combine_reply_to",
"combine_bounce_to",
"combine_disposition_notification_to",
"combine_attachments",
}
if target in direct_targets:
return True
if target.startswith("fields."):
name = target.split(".", 1)[1]
return not field_names or name in field_names
if target.startswith("from."):
return target in {"from.email", "from.name", "from.type"}
for prefix in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]:
if target.startswith(prefix + "."):
parts = target.split(".")
return len(parts) == 3 and parts[1].isdigit() and parts[2] in {"email", "name", "type"}
if target.startswith("attachments."):
parts = target.split(".")
# attachments.0.zip.filename_template etc.
if len(parts) >= 3 and parts[1].isdigit():
if parts[2] in {
"id",
"label",
"base_dir",
"file_filter",
"include_subdirs",
"required",
"allow_multiple",
"missing_behavior",
"ambiguous_behavior",
}:
return len(parts) == 3
if parts[2] == "zip" and len(parts) == 4:
return parts[3] in {"enabled", "mode", "filename_template", "password_mode", "password", "password_field", "password_template", "method"}
return False
def _csv_header(path: Path, delimiter: str, encoding: str) -> list[str] | None:
with path.open("r", encoding=encoding, newline="") as handle:
reader = csv.reader(handle, delimiter=delimiter)
try:
return next(reader)
except StopIteration:
return []
def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, str]]:
if not config.template.source:
return []
source = config.template.source
paths: list[tuple[str, str]] = []
if source.subject_path:
paths.append(("/template/source/subject_path", source.subject_path))
if source.text_path:
paths.append(("/template/source/text_path", source.text_path))
if source.html_path:
paths.append(("/template/source/html_path", source.html_path))
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):
if not entry.active:
continue
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 _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]:
collection = config.attachments.zip
issues: list[SemanticIssue] = []
if not collection.enabled:
return issues
if not collection.archives:
return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")]
archive_ids: set[str] = set()
archive_names: set[str] = set()
standard_count = 0
field_definitions = {field.name: field for field in config.fields}
for index, archive in enumerate(collection.archives):
path = f"/attachments/zip/archives/{index}"
if not archive.id.strip():
issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id"))
elif archive.id in archive_ids:
issues.append(_issue(Severity.ERROR, "zip_archive_id_duplicate", f"ZIP archive id {archive.id!r} is used more than once", f"{path}/id"))
archive_ids.add(archive.id)
normalized_archive_name = _normalized_zip_archive_name(archive.name)
if not normalized_archive_name:
issues.append(_issue(Severity.ERROR, "zip_archive_name_missing", "ZIP archive has no filename", f"{path}/name"))
elif normalized_archive_name in archive_names:
issues.append(_issue(Severity.ERROR, "zip_archive_name_duplicate", f"ZIP archive filename {archive.name!r} is used more than once; archive filenames must be unique", f"{path}/name"))
archive_names.add(normalized_archive_name)
if archive.standard:
standard_count += 1
if not archive.password_enabled:
continue
if archive.password_mode == ZipPasswordMode.DIRECT:
if not (archive.password or ""):
issues.append(_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password"))
continue
if archive.password_mode == ZipPasswordMode.TEMPLATE:
if not (archive.password_template or ""):
issues.append(_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template"))
continue
field_name = (archive.password_field or "").strip()
field = field_definitions.get(field_name)
if not field_name:
issues.append(_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field"))
elif field is None:
issues.append(_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field"))
elif field.type != FieldType.PASSWORD:
issues.append(_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field"))
elif archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""):
issues.append(_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}"))
if standard_count != 1:
issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives"))
for path, rule, _is_individual in _iter_attachment_rules(config):
selection = (rule.zip.archive_id or ZipRuleMode.INHERIT.value).strip()
if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value, ZipRuleMode.EXCLUDE.value} and selection not in archive_ids:
issues.append(_issue(Severity.ERROR, "zip_archive_unknown", f"Attachment rule selects unknown ZIP archive {selection!r}", f"{path}/zip/archive_id"))
return issues
def _normalized_zip_archive_name(value: str) -> str:
normalized = value.strip().casefold()
if not normalized:
return ""
return normalized if normalized.endswith(".zip") else f"{normalized}.zip"
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
return [
_issue(
Severity.WARNING,
"field_override_not_allowed",
f"recipient value for field {field_name!r} will be ignored because the field does not allow overrides",
f"{path_prefix}/fields/{field_name}",
)
for field_name in ignored_entry_field_overrides(config, entry)
]
def validate_campaign_config(
config: CampaignConfig,
*,
campaign_file: str | Path | None = None,
check_files: bool = False,
) -> SemanticReport:
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
issues: list[SemanticIssue] = []
field_names = config.field_names
field_definitions = {field.name: field for field in config.fields}
declared_names = set(field_definitions)
for key in config.global_values:
if declared_names and key not in declared_names:
issues.append(_issue(
Severity.WARNING,
"unknown_global_value",
f"global_values contains {key!r}, but it is not declared in fields",
f"/global_values/{key}",
))
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_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:
issues.append(_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
))
if config.delivery.imap_append_sent.enabled and not (config.server.imap and config.server.imap.enabled):
issues.append(_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but server.imap.enabled is not true",
"/delivery/imap_append_sent/enabled",
))
if config.campaign.mode == "send" and not config.server.smtp:
issues.append(_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
))
if config.server.smtp:
missing = [name for name in ["host", "port"] if getattr(config.server.smtp, name) in (None, "")]
if missing:
issues.append(_issue(
Severity.WARNING,
"incomplete_smtp_config",
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
"/server/smtp",
))
if config.entries.is_inline:
inline_entries = config.entries.inline or []
entries_count = len(inline_entries)
entries_mode = "inline"
if entries_count == 0:
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
for index, entry in enumerate(inline_entries):
if entry.active:
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
else:
entries_count = None
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
mapping = config.entries.mapping or {}
if not mapping:
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
for target in mapping:
if not _mapping_target_known(target, field_names):
issues.append(_issue(
Severity.WARNING,
"unknown_mapping_target",
f"mapping target {target!r} is not recognized by the current campaign model",
f"/entries/mapping/{target}",
))
field_name = _mapping_target_field_name(target)
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
issues.append(_issue(
Severity.WARNING,
"mapping_target_not_overridable",
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
f"/entries/mapping/{target}",
))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source:
source_path = _resolve(campaign_path, config.entries.source.path)
if not source_path.exists():
issues.append(_issue(
Severity.ERROR,
"entries_source_not_found",
f"entries source file does not exist: {source_path}",
"/entries/source/path",
))
elif config.entries.source.type == SourceType.CSV and config.entries.source.has_header:
try:
header = _csv_header(source_path, config.entries.source.delimiter, config.entries.source.encoding)
header_set = set(header or [])
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
if missing_columns:
issues.append(_issue(
Severity.ERROR,
"mapping_columns_missing",
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
"/entries/mapping",
))
except OSError as exc:
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
if check_files:
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():
issues.append(_issue(
Severity.ERROR,
"template_source_not_found",
f"template source file does not exist: {path}",
schema_path,
))
report = SemanticReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
issues=issues,
entries_mode=entries_mode,
entries_count=entries_count,
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,
)
return report

View File

@@ -0,0 +1,334 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import StrEnum
from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
try:
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
except (ImportError, ModuleNotFoundError):
pass
def new_uuid() -> str:
return str(uuid.uuid4())
class CampaignStatus(StrEnum):
DRAFT = "draft"
VALIDATED = "validated"
NEEDS_REVIEW = "needs_review"
READY_TO_QUEUE = "ready_to_queue"
QUEUED = "queued"
SENDING = "sending"
SENT = "sent"
PARTIALLY_COMPLETED = "partially_completed"
OUTCOME_UNKNOWN = "outcome_unknown"
FAILED = "failed"
CANCELLED = "cancelled"
ARCHIVED = "archived"
class CampaignVersionWorkflowState(StrEnum):
EDITING = "editing"
UNDER_REVIEW = "under_review"
APPROVED = "approved"
BUILT = "built"
QUEUED = "queued"
SENDING = "sending"
COMPLETED = "completed"
PARTIALLY_COMPLETED = "partially_completed"
OUTCOME_UNKNOWN = "outcome_unknown"
FAILED = "failed"
CANCELLED = "cancelled"
ARCHIVED = "archived"
class CampaignVersionFlow(StrEnum):
CREATE = "create"
REVIEW = "review"
SEND = "send"
MANUAL = "manual"
JSON = "json"
class JobBuildStatus(StrEnum):
PENDING = "pending"
BUILT = "built"
BUILD_FAILED = "build_failed"
class JobValidationStatus(StrEnum):
READY = "ready"
WARNING = "warning"
NEEDS_REVIEW = "needs_review"
BLOCKED = "blocked"
EXCLUDED = "excluded"
INACTIVE = "inactive"
class JobQueueStatus(StrEnum):
DRAFT = "draft"
QUEUED = "queued"
SENDING = "sending"
PAUSED = "paused"
CANCELLED = "cancelled"
class JobSendStatus(StrEnum):
NOT_QUEUED = "not_queued"
QUEUED = "queued"
CLAIMED = "claimed"
SENDING = "sending"
SMTP_ACCEPTED = "smtp_accepted"
SENT = "sent" # legacy value retained for existing databases/reports
OUTCOME_UNKNOWN = "outcome_unknown"
FAILED_TEMPORARY = "failed_temporary"
FAILED_PERMANENT = "failed_permanent"
CANCELLED = "cancelled"
class JobImapStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
APPENDED = "appended"
FAILED = "failed"
SKIPPED = "skipped"
class IssueSeverity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
class Campaign(Base, TimestampMixin):
__tablename__ = "campaigns"
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True)
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(50), default=CampaignStatus.DRAFT.value, nullable=False, index=True)
current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
tenant: Mapped[Tenant] = relationship()
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
class CampaignShare(Base, TimestampMixin):
__tablename__ = "campaign_shares"
__table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
class CampaignVersion(Base, TimestampMixin):
__tablename__ = "campaign_versions"
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False)
source_filename: Mapped[str | None] = mapped_column(String(500))
source_base_path: Mapped[str | None] = mapped_column(String(1000))
# Editor/workflow metadata used by the WebUI and future desktop clients.
# A campaign version can be the autosaved working copy of a new or existing
# campaign, so no separate CampaignDraft entity is needed.
workflow_state: Mapped[str] = mapped_column(
String(50),
default=CampaignVersionWorkflowState.EDITING.value,
nullable=False,
index=True,
)
current_flow: Mapped[str] = mapped_column(
String(50),
default=CampaignVersionFlow.MANUAL.value,
nullable=False,
index=True,
)
current_step: Mapped[str | None] = mapped_column(String(100))
is_complete: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
editor_state: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
# Explicit user-requested lock. This is deliberately separate from
# locked_at, which represents the reversible validation lock used by the
# build/send workflow. Temporary user locks may later receive a dedicated
# RBAC permission for unlocking; permanent locks never unlock in place.
user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
execution_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
execution_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
campaign: Mapped[Campaign] = relationship(back_populates="versions")
class CampaignJob(Base, TimestampMixin):
__tablename__ = "campaign_jobs"
__table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
entry_id: Mapped[str | None] = mapped_column(String(255), index=True)
recipient_email: Mapped[str | None] = mapped_column(String(320), index=True)
subject: Mapped[str | None] = mapped_column(String(998))
message_id_header: Mapped[str | None] = mapped_column(String(255))
eml_storage_key: Mapped[str | None] = mapped_column(String(1000))
eml_local_path: Mapped[str | None] = mapped_column(String(1000))
eml_size_bytes: Mapped[int | None] = mapped_column(Integer)
eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True)
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True)
send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True)
imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_error: Mapped[str | None] = mapped_column(Text)
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
campaign: Mapped[Campaign] = relationship(back_populates="jobs")
class CampaignIssue(Base, TimestampMixin):
__tablename__ = "campaign_issues"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True)
job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True)
severity: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
message: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[str | None] = mapped_column(String(255))
behavior: Mapped[str | None] = mapped_column(String(50))
class AttachmentBlob(Base, TimestampMixin):
__tablename__ = "attachment_blobs"
__table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(255))
storage_bucket: Mapped[str] = mapped_column(String(255), nullable=False)
storage_key: Mapped[str] = mapped_column(String(1000), nullable=False)
class AttachmentInstance(Base, TimestampMixin):
__tablename__ = "attachment_instances"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True)
logical_name: Mapped[str | None] = mapped_column(String(500))
filename: Mapped[str] = mapped_column(String(500), nullable=False)
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
class SendAttempt(Base, TimestampMixin):
__tablename__ = "send_attempts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(50), default="started", nullable=False, index=True)
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
smtp_status_code: Mapped[int | None] = mapped_column(Integer)
smtp_response: Mapped[str | None] = mapped_column(Text)
error_type: Mapped[str | None] = mapped_column(String(255))
error_message: Mapped[str | None] = mapped_column(Text)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class ImapAppendAttempt(Base, TimestampMixin):
__tablename__ = "imap_append_attempts"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
folder: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str] = mapped_column(String(50), nullable=False)
error_message: Mapped[str | None] = mapped_column(Text)
__all__ = [
"AttachmentBlob",
"AttachmentInstance",
"Campaign",
"CampaignIssue",
"CampaignJob",
"CampaignShare",
"CampaignStatus",
"CampaignVersion",
"CampaignVersionFlow",
"CampaignVersionWorkflowState",
"Group",
"ImapAppendAttempt",
"IssueSeverity",
"JobBuildStatus",
"JobImapStatus",
"JobQueueStatus",
"JobSendStatus",
"JobValidationStatus",
"SendAttempt",
"Tenant",
"User",
"UserGroupMembership",
]

View File

@@ -0,0 +1,290 @@
from __future__ import annotations
from email import policy
from email.message import EmailMessage
from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
prepared_campaign_snapshot,
public_attachment_summary_payload,
)
from govoplan_mail.backend.dev.mock_mailbox import (
clear_records,
consume_fail_next_imap,
consume_fail_next_smtp,
get_failures,
list_records,
record_imap_append,
record_smtp_delivery,
)
class MockCampaignSendError(RuntimeError):
pass
def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None:
if address is None:
return None
return {"email": address.email, "name": address.name}
def _message_addresses_payload(addresses: list[MessageAddress]) -> list[dict[str, Any]]:
return [{"email": item.email, "name": item.name} for item in addresses]
def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]:
return [issue.model_dump(mode="json") for issue in message.issues]
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
return [public_attachment_summary_payload(attachment) for attachment in message.attachments]
def _message_payload(message: MessageDraft) -> dict[str, Any]:
return {
"entry_index": message.entry_index,
"entry_id": message.entry_id,
"active": message.active,
"subject": message.subject,
"from": _message_address_payload(message.from_),
"from_all": _message_addresses_payload(message.from_all),
"to": _message_addresses_payload(message.to),
"cc": _message_addresses_payload(message.cc),
"bcc": _message_addresses_payload(message.bcc),
"reply_to": _message_addresses_payload(message.reply_to),
"build_status": str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status),
"validation_status": message.validation_status.value,
"send_status": str(message.send_status.value if hasattr(message.send_status, "value") else message.send_status),
"imap_status": message.imap_status.value,
"attachment_count": message.attachment_count,
"attachments": _attachment_payloads(message),
"issues": _issue_payloads(message),
"eml_size_bytes": message.eml_size_bytes,
"queueable": message.is_queueable,
}
def _recipient_emails(message: MessageDraft) -> list[str]:
values = [item.email for item in message.to + message.cc + message.bcc if item.email]
return list(dict.fromkeys(values))
def _envelope_from(message: MessageDraft, *, fallback: str = "mock-sender@mock.local") -> str:
if message.bounce_to:
return message.bounce_to[0].email
if message.from_ and message.from_.email:
return message.from_.email
return fallback
def _raw_message_bytes(message: EmailMessage) -> bytes:
return message.as_bytes(policy=policy.SMTP)
def _smtp_rejection_matches(recipients: list[str]) -> list[str]:
needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower()
if not needle:
return []
return [recipient for recipient in recipients if needle in recipient.lower()]
def _can_mock_send(
message: MessageDraft,
*,
include_warnings: bool,
include_needs_review: bool,
) -> tuple[bool, str | None]:
if not message.active:
return False, "Recipient is inactive"
if str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status) != "built":
return False, f"Message is not built ({message.build_status})"
if message.validation_status == MessageValidationStatus.READY:
return True, None
if message.validation_status == MessageValidationStatus.WARNING and include_warnings:
return True, None
if message.validation_status == MessageValidationStatus.NEEDS_REVIEW and include_needs_review:
return True, None
return False, f"Validation status is {message.validation_status.value}"
def run_mock_campaign_send(
session: Session,
*,
tenant_id: str,
campaign_id: str,
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,
) -> dict[str, Any]:
"""Validate, build and optionally mock-send a version without mutating it.
This is a dev/test route. It does not change campaign/version status, does
not queue real jobs and does not use the configured SMTP/IMAP servers. It
records mock SMTP deliveries and mock IMAP appends in the integrated mock
mailbox only when send=True.
"""
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
if not campaign:
raise MockCampaignSendError("Campaign not found or not accessible")
wanted_version_id = version_id or campaign.current_version_id
if not wanted_version_id:
raise MockCampaignSendError("Campaign has no current version")
version = session.get(CampaignVersion, wanted_version_id)
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError("Campaign version not found or not part of campaign")
if clear_mailbox:
clear_records()
with prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-mock-send-",
) as prepared:
prepared_raw = load_campaign_json(prepared.path)
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
send_results: list[dict[str, Any]] = []
sent_count = 0
failed_count = 0
skipped_count = 0
imap_appended_count = 0
imap_failed_count = 0
for built in build_result.built_messages:
draft = built.draft
can_send, skip_reason = _can_mock_send(draft, include_warnings=include_warnings, include_needs_review=include_needs_review)
row: dict[str, Any] = {
"entry_index": draft.entry_index,
"entry_id": draft.entry_id,
"subject": draft.subject,
"validation_status": draft.validation_status.value,
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status),
"to": _message_addresses_payload(draft.to),
"attachments": _attachment_payloads(draft),
"issues": _issue_payloads(draft),
}
if not can_send or built.mime is None:
skipped_count += 1
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
send_results.append(row)
continue
recipients = _recipient_emails(draft)
envelope_from = _envelope_from(draft)
if not recipients:
skipped_count += 1
row.update({"status": "skipped", "message": "No envelope recipients"})
send_results.append(row)
continue
if not send:
row.update({"status": "ready", "message": f"Would send to {len(recipients)} recipient(s)", "envelope_from": envelope_from, "envelope_recipients": recipients})
send_results.append(row)
continue
try:
if consume_fail_next_smtp():
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
rejected = _smtp_rejection_matches(recipients)
if rejected and len(rejected) == len(recipients):
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
accepted = [recipient for recipient in recipients if recipient not in rejected]
smtp_record = record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
sent_count += 1
row.update({
"status": "sent",
"message": f"Mock SMTP captured as {smtp_record.id}",
"smtp_message_id": smtp_record.id,
"envelope_from": envelope_from,
"envelope_recipients": accepted,
"refused_recipients": rejected,
})
if append_sent:
try:
if consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = "Sent"
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
folder = config.delivery.imap_append_sent.folder
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
folder = config.server.imap.sent_folder
imap_record = record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
imap_appended_count += 1
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
except Exception as exc:
imap_failed_count += 1
row.update({"imap_status": "failed", "imap_error": str(exc)})
except Exception as exc:
failed_count += 1
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
send_results.append(row)
validation_json = validation_report.model_dump(mode="json")
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
build_report = build_result.report
build_json = build_report.model_dump(mode="json")
build_json.update({
"built_count": build_report.built_count,
"queueable_count": build_report.queueable_count,
"needs_review_count": build_report.needs_review_count,
"blocked_count": build_report.blocked_count,
"warning_count": build_report.warning_count,
"ready_count": build_report.ready_count,
"messages": [_message_payload(message) for message in build_report.messages],
})
attempted_count = sum(1 for row in send_results if row.get("status") in {"sent", "failed"})
return {
"campaign_id": campaign.id,
"version_id": version.id,
"version_number": version.version_number,
"send_requested": send,
"include_warnings": include_warnings,
"include_needs_review": include_needs_review,
"append_sent": append_sent,
"steps": [
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
{"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if failed_count == 0 else "needs_review"), "summary": {"attempted": attempted_count, "sent": sent_count, "failed": failed_count, "skipped": skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if imap_failed_count == 0 else "needs_review"), "summary": {"appended": imap_appended_count, "failed": imap_failed_count}},
],
"validation": validation_json,
"build": build_json,
"send": {
"attempted_count": attempted_count,
"sent_count": sent_count,
"failed_count": failed_count,
"skipped_count": skipped_count,
"imap_appended_count": imap_appended_count,
"imap_failed_count": imap_failed_count,
"results": send_results,
},
"mailbox": {"messages": list_records(limit=200)},
}

View File

@@ -0,0 +1,210 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from .fields import Field, FieldConfiguration, FieldContents
from .recipients import Recipient, RecipientList
from .template import MailTemplate
class TransportSecurity(StrEnum):
PLAIN = "plain"
TLS = "tls"
STARTTLS = "starttls"
@property
def standard_port(self) -> int:
return 465 if self == TransportSecurity.TLS else 587
@dataclass
class MailServerSettings:
server: str = ""
port: int | None = None
username: str = ""
password: str = ""
transport_security: TransportSecurity = TransportSecurity.PLAIN
def use_plain(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.PLAIN
self.port = self.port or self.transport_security.standard_port
return self
def use_tls(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.TLS
self.port = self.port or self.transport_security.standard_port
return self
def use_starttls(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.STARTTLS
self.port = self.port or self.transport_security.standard_port
return self
def resolved_port(self) -> int:
return self.port or self.transport_security.standard_port
@dataclass(frozen=True, slots=True)
class MailAttachmentConfig:
base_dir: Path
file_filter: str = "*"
include_subdirs: bool = False
@dataclass
class MailEntry:
field_config: FieldConfiguration
is_active: bool = True
from_recipient: Recipient | None = None
to: RecipientList = field(default_factory=RecipientList)
cc: RecipientList = field(default_factory=RecipientList)
bcc: RecipientList = field(default_factory=RecipientList)
combine_to: bool = True
combine_cc: bool = True
combine_bcc: bool = True
attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
combine_attachments: bool = True
field_contents: FieldContents = field(init=False)
def __post_init__(self) -> None:
self.field_contents = FieldContents(self.field_config)
def add_to(self, recipient: Recipient) -> "MailEntry":
self.to.add_recipient(recipient)
return self
def add_cc(self, recipient: Recipient) -> "MailEntry":
self.cc.add_recipient(recipient)
return self
def add_bcc(self, recipient: Recipient) -> "MailEntry":
self.bcc.add_recipient(recipient)
return self
def no_combine_to(self) -> "MailEntry":
self.combine_to = False
return self
def combine_to_recipients(self) -> "MailEntry":
self.combine_to = True
return self
def no_combine_attachments(self) -> "MailEntry":
self.combine_attachments = False
return self
def combine_attachments_with_global(self) -> "MailEntry":
self.combine_attachments = True
return self
def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry":
self.attachment_configs.append(config)
return self
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry":
if not self.field_contents.set_field_content_for_name(name, value):
raise KeyError(f"unknown field: {name}")
return self
def get_field_content_from_name(self, name: str) -> Field:
return self.field_contents.get_field_content_from_name(name)
@dataclass
class MailCampaign:
mail_server_settings: MailServerSettings | None = None
global_from: Recipient | None = None
global_to: RecipientList = field(default_factory=RecipientList)
global_cc: RecipientList = field(default_factory=RecipientList)
global_bcc: RecipientList = field(default_factory=RecipientList)
individual_from: bool = False
individual_to: bool = False
individual_cc: bool = False
individual_bcc: bool = False
base_attachment_path: Path = Path(".")
global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
individual_attachments: bool = False
send_without_attachments: bool = True
field_config: FieldConfiguration = field(default_factory=FieldConfiguration)
field_contents: FieldContents = field(init=False)
subject_template: MailTemplate = field(default_factory=MailTemplate)
mail_template: MailTemplate = field(default_factory=MailTemplate)
mail_entries: list[MailEntry] = field(default_factory=list)
def __post_init__(self) -> None:
self.field_contents = FieldContents(self.field_config)
@classmethod
def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign":
return cls(mail_server_settings=settings)
def add_field(self, name: str, field_type) -> "MailCampaign":
from .fields import FieldDescription
self.field_config.add_field_at_end(FieldDescription(name, field_type))
self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
for entry in self.mail_entries:
entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
return self
def set_from(self, recipient: Recipient) -> "MailCampaign":
self.global_from = recipient
return self
def add_to(self, recipient: Recipient) -> "MailCampaign":
self.global_to.add_recipient(recipient)
return self
def allow_individual_to(self) -> "MailCampaign":
self.individual_to = True
return self
def disallow_individual_to(self) -> "MailCampaign":
self.individual_to = False
return self
def allow_individual_attachments(self) -> "MailCampaign":
self.individual_attachments = True
return self
def disallow_individual_attachments(self) -> "MailCampaign":
self.individual_attachments = False
return self
def dont_send_without_attachments(self) -> "MailCampaign":
self.send_without_attachments = False
return self
def send_without_attachments_allowed(self) -> "MailCampaign":
self.send_without_attachments = True
return self
def add_new_mail_entry(self) -> MailEntry:
entry = MailEntry(self.field_config)
self.mail_entries.append(entry)
return entry
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign":
if not self.field_contents.set_field_content_for_name(name, value):
raise KeyError(f"unknown field: {name}")
return self
def get_field_content_from_name(self, name: str) -> Field:
return self.field_contents.get_field_content_from_name(name)
def all_recipients_for(self, entry: MailEntry) -> list[Recipient]:
recipients: list[Recipient] = []
if not self.individual_to or entry.combine_to:
recipients.extend(self.global_to.recipients)
if not self.individual_cc or entry.combine_cc:
recipients.extend(self.global_cc.recipients)
if not self.individual_bcc or entry.combine_bcc:
recipients.extend(self.global_bcc.recipients)
if self.individual_to:
recipients.extend(entry.to.recipients)
if self.individual_cc:
recipients.extend(entry.cc.recipients)
if self.individual_bcc:
recipients.extend(entry.bcc.recipients)
return recipients

View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from enum import StrEnum
from typing import Any
class FieldType(StrEnum):
STRING = "string"
INTEGER = "integer"
DOUBLE = "double"
DATE = "date"
PASSWORD = "password"
@dataclass(slots=True)
class FieldDescription:
name: str
type: FieldType = FieldType.STRING
can_override: bool = True
@dataclass(slots=True)
class Field:
content: Any
@classmethod
def with_content(cls, content: Any) -> "Field":
if content is None:
raise ValueError("content must not be None")
return cls(content=content)
@property
def type(self) -> FieldType:
if isinstance(self.content, bool):
return FieldType.STRING
if isinstance(self.content, int):
return FieldType.INTEGER
if isinstance(self.content, float):
return FieldType.DOUBLE
if isinstance(self.content, (date, datetime)):
return FieldType.DATE
if isinstance(self.content, (bytes, bytearray)):
return FieldType.PASSWORD
return FieldType.STRING
def as_string(self) -> str:
if isinstance(self.content, (bytes, bytearray)):
return self.content.decode("utf-8")
if isinstance(self.content, (date, datetime)):
return self.content.isoformat()
return str(self.content)
@dataclass
class FieldConfiguration:
fields: list[FieldDescription] = field(default_factory=list)
def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration":
return self.add_field_at_position(len(self.fields), field_description)
def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration":
return self.add_field_at_position(0, field_description)
def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration":
if self.has_field(field_description.name):
raise ValueError(f"field already exists: {field_description.name}")
position = max(0, min(position, len(self.fields)))
self.fields.insert(position, field_description)
return self
def has_field(self, name: str) -> bool:
return any(f.name == name for f in self.fields)
def get_field_description(self, name: str) -> FieldDescription | None:
return next((f for f in self.fields if f.name == name), None)
def get_field_names(self) -> list[str]:
return [f.name for f in self.fields]
@dataclass
class FieldContents:
field_config: FieldConfiguration
field_map: dict[str, Field] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_description in self.field_config.fields:
self.ensure_field(field_description)
def ensure_field(self, field_description: FieldDescription) -> None:
if field_description.name in self.field_map:
return
match field_description.type:
case FieldType.INTEGER:
value = 0
case FieldType.DOUBLE:
value = 0.0
case FieldType.DATE:
value = date.today()
case FieldType.PASSWORD:
value = b""
case _:
value = ""
self.field_map[field_description.name] = Field.with_content(value)
def get_field_content_from_name(self, name: str) -> Field:
try:
return self.field_map[name]
except KeyError as exc:
raise KeyError(f"unknown field: {name}") from exc
def set_field_content_for_name(self, name: str, value: Field | Any) -> bool:
if name not in self.field_map:
return False
if not isinstance(value, Field):
value = Field.with_content(value)
expected = self.field_map[name].type
if expected != value.type and expected != FieldType.PASSWORD:
raise TypeError(f"field {name!r} expects {expected}, got {value.type}")
self.field_map[name] = value
return True
def as_value_map(self, prefix: str) -> dict[str, str]:
return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()}

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from dataclasses import dataclass, field
from email.message import EmailMessage
from typing import Iterator
@dataclass
class MailQueue:
messages: list[EmailMessage] = field(default_factory=list)
def add_mail(self, message: EmailMessage) -> None:
self.messages.append(message)
def remove_mail(self, message: EmailMessage) -> bool:
if message in self.messages:
self.messages.remove(message)
return True
return False
@property
def mail_count(self) -> int:
return len(self.messages)
def is_empty(self) -> bool:
return not self.messages
def __iter__(self) -> Iterator[EmailMessage]:
return iter(self.messages)

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from dataclasses import dataclass, field
from email.utils import formataddr
from enum import StrEnum
class RecipientType(StrEnum):
TO = "to"
CC = "cc"
BCC = "bcc"
@dataclass(frozen=True, slots=True)
class Recipient:
address: str
name: str | None = None
type: RecipientType = RecipientType.TO
def formatted(self) -> str:
return formataddr((self.name or self.address, self.address))
@dataclass
class RecipientList:
recipients: list[Recipient] = field(default_factory=list)
def add_recipient(self, recipient: Recipient) -> "RecipientList":
if recipient not in self.recipients:
self.recipients.append(recipient)
return self
def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList":
for recipient in recipients:
self.add_recipient(recipient)
return self
def clear_all_recipients(self) -> "RecipientList":
self.recipients.clear()
return self
def by_type(self, recipient_type: RecipientType) -> list[Recipient]:
return [r for r in self.recipients if r.type == recipient_type]

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import re
from dataclasses import dataclass
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
@dataclass
class MailTemplate:
template_string: str = ""
def set_template_string(self, template: str) -> "MailTemplate":
self.template_string = template
return self
def get_used_fields(self) -> set[str]:
return set(_FIELD_PATTERN.findall(self.template_string))
def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group(1)
if key in values:
return values[key]
return match.group(0) if keep_missing else ""
rendered = _FIELD_PATTERN.sub(replace, self.template_string)
return rendered.replace(r"\${", "${").replace(r"\}", "}")

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category=category,
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission("campaigns:campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"),
_permission("campaigns:campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"),
_permission("campaigns:campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"),
_permission("campaigns:campaign:copy", "Copy campaigns", "Create campaigns or working versions from existing campaigns.", "Campaigns"),
_permission("campaigns:campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"),
_permission("campaigns:campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"),
_permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"),
_permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
_permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
_permission("campaigns:campaign:review", "Approve campaign review", "Approve or reject built messages and review conditions.", "Campaigns"),
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
_permission("campaigns:recipient:export", "Export recipients", "Export recipient data and reports.", "Recipients"),
_permission("campaigns:report:read", "View reports", "View campaign delivery reports and aggregate outcomes.", "Reports"),
_permission("campaigns:report:export", "Export reports", "Download detailed campaign reports.", "Reports"),
_permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="campaign_manager",
name="Campaign manager",
description="Prepare, validate and build campaigns without approving real delivery.",
permissions=(
"campaigns:campaign:read",
"campaigns:campaign:create",
"campaigns:campaign:update",
"campaigns:campaign:copy",
"campaigns:campaign:validate",
"campaigns:campaign:build",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
"campaigns:report:read",
),
),
RoleTemplate(
slug="campaign_reviewer",
name="Campaign reviewer",
description="Inspect and approve prepared campaign messages.",
permissions=(
"campaigns:campaign:read",
"campaigns:campaign:validate",
"campaigns:campaign:review",
"campaigns:recipient:read",
"campaigns:report:read",
),
),
RoleTemplate(
slug="campaign_sender",
name="Campaign sender",
description="Queue, test, control, send and reconcile prepared campaigns.",
permissions=(
"campaigns:campaign:read",
"campaigns:campaign:send_test",
"campaigns:campaign:queue",
"campaigns:campaign:control",
"campaigns:campaign:send",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:recipient:read",
"campaigns:report:read",
"campaigns:report:send",
),
),
)
def _campaigns_router(context: ModuleContext):
del context
from govoplan_campaign.backend.router import router
return router
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="1.0.0",
dependencies=("access", "files", "mail"),
optional_dependencies=(),
permissions=PERMISSIONS,
route_factory=_campaigns_router,
role_templates=ROLE_TEMPLATES,
nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="mail-check", required_any=("campaigns:campaign:read",), order=20),
NavItem(
path="/operator",
label="Operator Queue",
icon="activity",
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:campaign:control",
"campaigns:campaign:send",
),
order=30,
),
NavItem(path="/reports", label="Reports", icon="file-text", required_any=("campaigns:report:read",), order=70),
),
frontend=FrontendModule(
module_id="campaigns",
package_name="@govoplan/campaign-webui",
nav_items=(
NavItem(path="/campaigns", label="Campaigns", icon="mail-check", required_any=("campaigns:campaign:read",), order=20),
NavItem(
path="/operator",
label="Operator Queue",
icon="activity",
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
"campaigns:campaign:reconcile",
"campaigns:campaign:control",
"campaigns:campaign:send",
),
order=30,
),
NavItem(path="/reports", label="Reports", icon="file-text", required_any=("campaigns:report:read",), order=70),
NavItem(path="/address-book", label="Address Book", icon="users", order=80),
NavItem(path="/templates", label="Templates", icon="form", order=90),
),
),
migration_spec=MigrationSpec(
module_id="campaigns",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1,12 @@
"""Message building and review helpers."""
from .builder import build_campaign_messages
from .models import CampaignBuildReport, MessageDraft, MessageIssue, MessageValidationStatus
__all__ = [
"build_campaign_messages",
"CampaignBuildReport",
"MessageDraft",
"MessageIssue",
"MessageValidationStatus",
]

View File

@@ -0,0 +1,618 @@
from __future__ import annotations
import mimetypes
import re
import tempfile
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import make_msgid, formatdate
from pathlib import Path
from typing import Any, Iterable
from govoplan_campaign.backend.attachments.resolver import (
AttachmentMatchStatus,
EntryAttachmentResolution,
MessageAttachmentStatus,
ResolvedAttachment,
resolve_entry_attachments,
)
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
from govoplan_campaign.backend.campaign.models import (
Behavior,
BuildStatus,
CampaignConfig,
EntryConfig,
MissingAddressBehavior,
RecipientConfig,
SendStatus,
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
)
from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.services.zip_service import create_zip_archive
from .models import (
CampaignBuildReport,
ImapStatus,
MessageAddress,
MessageAttachmentSummary,
MessageDraft,
MessageIssue,
MessageValidationStatus,
)
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
def _normalize_template_key(raw: str) -> str:
key = raw.strip()
if key.startswith("fields."):
key = key.removeprefix("fields.")
elif key.startswith("local."):
key = "local::" + key.removeprefix("local.")
elif key.startswith("global."):
key = "global::" + key.removeprefix("global.")
if key.startswith("local::") or key.startswith("global::"):
return key
if key.startswith("local:"):
return "local::" + key.removeprefix("local:")
if key.startswith("global:"):
return "global::" + key.removeprefix("global:")
return key
@dataclass(slots=True)
class BuiltMessage:
draft: MessageDraft
mime: EmailMessage | None
@dataclass(slots=True)
class CampaignBuildResult:
report: CampaignBuildReport
built_messages: list[BuiltMessage]
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
return (campaign_path.parent / path).resolve()
def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str = "utf-8") -> str | None:
if not raw_path:
return None
path = _resolve(campaign_file, raw_path)
return path.read_text(encoding=encoding)
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = _normalize_template_key(match.group(1))
if key in values:
value = values[key]
return "" if value is None else str(value)
return match.group(0) if keep_missing else ""
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
return rendered.replace(r"\${", "${").replace(r"\}", "}")
def _find_unresolved_placeholders(text: str | None) -> set[str]:
if not text:
return set()
return {
_normalize_template_key(match.group(1))
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
for match in pattern.finditer(text)
}
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
if recipient is None:
return None
return MessageAddress(email=recipient.email, name=recipient.name)
def _message_addresses(recipients: Iterable[RecipientConfig]) -> list[MessageAddress]:
return [MessageAddress(email=recipient.email, name=recipient.name) for recipient in recipients]
def _format_recipient(recipient: RecipientConfig) -> str:
return formatted_recipient(recipient)
def _format_recipient_header(recipients: Iterable[RecipientConfig]) -> str:
return ", ".join(_format_recipient(recipient) for recipient in recipients)
def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> tuple[str, str | None, str | None]:
template = config.template
if template.source:
subject = _read_text(campaign_file, template.source.subject_path, template.source.encoding)
text = _read_text(campaign_file, template.source.text_path, template.source.encoding)
html = _read_text(campaign_file, template.source.html_path, template.source.encoding)
return subject or "", text, html
return template.subject or "", template.text, template.html
def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue:
severity = "error" if behavior == "block" else "warning"
return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source)
def _apply_behavior(current: MessageValidationStatus, behavior: str) -> MessageValidationStatus:
if behavior == Behavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
if behavior == Behavior.DROP.value:
return MessageValidationStatus.EXCLUDED
if behavior == Behavior.ASK.value:
if current not in {MessageValidationStatus.BLOCKED, MessageValidationStatus.EXCLUDED}:
return MessageValidationStatus.NEEDS_REVIEW
if behavior == Behavior.WARN.value:
if current == MessageValidationStatus.READY:
return MessageValidationStatus.WARNING
# continue leaves status as-is
return current
def _validation_status_from_attachment_status(status: MessageAttachmentStatus) -> MessageValidationStatus:
return MessageValidationStatus(status.value)
def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[MessageAttachmentSummary]:
return [
MessageAttachmentSummary(
attachment_id=attachment.attachment_id,
label=attachment.label,
status=attachment.status.value,
behavior=attachment.behavior.value if attachment.behavior else None,
required=attachment.required,
allow_multiple=attachment.allow_multiple,
zip_enabled=attachment.zip_enabled,
zip_mode=attachment.zip_mode.value,
zip_archive_id=attachment.zip_archive_id,
zip_filename=attachment.zip_filename,
base_path_name=attachment.base_path_name,
base_path=attachment.base_path,
file_filter=attachment.file_filter,
directory=attachment.directory,
matches=attachment.matches,
)
for attachment in resolution.attachments
]
def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolution) -> list[MessageIssue]:
return [
MessageIssue(
severity=issue.severity.value,
code=issue.code,
message=issue.message,
behavior=issue.behavior.value if issue.behavior else None,
source="attachments",
)
for issue in resolution.issues
]
def _safe_filename(value: str | None, fallback: str) -> str:
raw = value or fallback
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
return safe or fallback
def _attachment_bytes(path: Path) -> tuple[bytes, str, str]:
data = path.read_bytes()
mime_type, _ = mimetypes.guess_type(str(path))
if not mime_type:
return data, "application", "octet-stream"
maintype, subtype = mime_type.split("/", 1)
return data, maintype, subtype
class ZipBuildError(RuntimeError):
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
def _zip_password(archive: ZipArchiveConfig, values: dict[str, Any]) -> str:
if not archive.password_enabled:
return ""
# Preserve support for campaigns written by the original single-archive
# implementation while all new archives use field + scope.
mode = archive.password_mode
if mode == ZipPasswordMode.DIRECT:
password = archive.password or ""
if not password:
raise ZipBuildError("zip_password_missing", f"ZIP archive {archive.name!r} has no fixed password")
return password
if mode == ZipPasswordMode.TEMPLATE:
password = _render_template(archive.password_template or "", values, keep_missing=False)
if not password:
raise ZipBuildError("zip_password_value_missing", f"ZIP archive {archive.name!r} produced an empty password")
return password
field_name = (archive.password_field or "").strip()
if not field_name:
raise ZipBuildError("zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no password field")
scope = archive.password_scope.value if isinstance(archive.password_scope, ZipPasswordScope) else str(archive.password_scope)
value = values.get(f"{scope}::{field_name}")
# Legacy field-based configurations did not declare a scope.
if value in (None, "") and mode == ZipPasswordMode.FIELD:
value = values.get(field_name)
password = "" if value is None else str(value)
if not password:
qualifier = "campaign-global" if scope == ZipPasswordScope.GLOBAL.value else "recipient"
raise ZipBuildError(
"zip_password_value_missing",
f"The {qualifier} ZIP password field {field_name!r} is empty for archive {archive.name!r}",
)
return password
def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_index: int) -> str:
rendered = _render_template(archive.name or "attachments.zip", values, keep_missing=False)
filename = _safe_filename(rendered, f"entry-{entry_index:04d}-attachments.zip")
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
def _deduplicated_paths(paths: list[Path]) -> list[Path]:
unique: list[Path] = []
seen: set[str] = set()
for path in paths:
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
unique.append(path)
return unique
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
candidate = filename
path = Path(filename)
counter = 2
while candidate.casefold() in used:
candidate = f"{path.stem} ({counter}){path.suffix}"
counter += 1
used.add(candidate.casefold())
return candidate
def _attach_files(
*,
message: EmailMessage,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
resolution: EntryAttachmentResolution,
values: dict[str, Any],
work_dir: Path,
) -> int:
attached_count = 0
archive_members: dict[str, list[Path]] = {}
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
used_message_filenames: set[str] = set()
for attachment in resolution.attachments:
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
continue
match_paths = [Path(match) for match in attachment.matches]
if attachment.zip_enabled and attachment.zip_archive_id:
archive_members.setdefault(attachment.zip_archive_id, []).extend(match_paths)
archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment)
continue
for path in match_paths:
filename = _unique_attachment_filename(path.name, used_message_filenames)
data, maintype, subtype = _attachment_bytes(path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
attached_count += 1
for archive in config.attachments.zip.archives:
members = _deduplicated_paths(archive_members.get(archive.id, []))
if not members:
continue
filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames)
password = _zip_password(archive, values)
archive_path = create_zip_archive(
work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename,
members,
password,
)
data, maintype, subtype = _attachment_bytes(archive_path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
attached_count += 1
for attachment in archive_attachments.get(archive.id, []):
attachment.zip_filename = filename
return attached_count
def _imap_initial_status(config: CampaignConfig) -> ImapStatus:
if config.delivery.imap_append_sent.enabled:
return ImapStatus.PENDING
return ImapStatus.NOT_REQUESTED
def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entry_index: int) -> tuple[str, int]:
output_dir.mkdir(parents=True, exist_ok=True)
filename = _safe_filename(entry.id, f"entry-{entry_index:04d}") + ".eml"
path = output_dir / filename
path.write_bytes(bytes(message))
return str(path), path.stat().st_size
def build_entry_message(
*,
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
output_dir: Path | None = None,
write_eml: bool = False,
work_dir: Path | None = None,
) -> BuiltMessage:
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index)
effective_addresses = effective_address_lists(config, entry)
senders = effective_addresses["from"]
sender = senders[0] if senders else None
recipients = {key: value for key, value in effective_addresses.items() if key != "from"}
issues = _message_issues_from_attachment_resolution(resolution)
validation_status = _validation_status_from_attachment_status(resolution.status)
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
if ignored_field_overrides:
issues.append(
MessageIssue(
severity="warning",
code="field_override_not_allowed",
message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides),
behavior="warn",
source="fields",
)
)
if validation_status == MessageValidationStatus.READY:
validation_status = MessageValidationStatus.WARNING
if not entry.active:
draft = MessageDraft(
entry_index=entry_index,
entry_id=entry.id,
active=False,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.INACTIVE,
send_status=SendStatus.DRAFT,
imap_status=ImapStatus.SKIPPED,
from_=_message_address(sender),
from_all=_message_addresses(senders),
to=_message_addresses(recipients["to"]),
cc=_message_addresses(recipients["cc"]),
bcc=_message_addresses(recipients["bcc"]),
reply_to=_message_addresses(recipients["reply_to"]),
bounce_to=_message_addresses(recipients["bounce_to"]),
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
attachments=_attachment_summaries(resolution),
issues=[MessageIssue(severity="info", code="inactive_entry", message="Entry is inactive", behavior=config.validation_policy.inactive_entry.value, source="entry")],
)
return BuiltMessage(draft=draft, mime=None)
if not recipients["to"]:
behavior = config.validation_policy.missing_email.value
issues.append(_issue_from_behavior(code="missing_email", message="No effective To recipient is configured", behavior=behavior, source="recipients"))
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
values = build_template_values(config, entry)
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders) if text_template is not None else None
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None
unresolved = sorted(
_find_unresolved_placeholders(subject)
| _find_unresolved_placeholders(text_body)
| _find_unresolved_placeholders(html_body)
)
if unresolved:
behavior = config.validation_policy.template_error.value
issues.append(
_issue_from_behavior(
code="template_error",
message="Unresolved template placeholder(s): " + ", ".join(unresolved),
behavior=behavior,
source="template",
)
)
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
message = EmailMessage()
try:
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid()
if senders:
message["From"] = _format_recipient_header(senders)
if len(senders) > 1:
# RFC 5322 requires a singular Sender when From contains more
# than one mailbox. The first effective From address remains
# the SMTP envelope sender and compatibility primary value.
message["Sender"] = _format_recipient(senders[0])
if recipients["to"]:
message["To"] = _format_recipient_header(recipients["to"])
if recipients["cc"]:
message["Cc"] = _format_recipient_header(recipients["cc"])
# Bcc deliberately remains envelope-only and is tracked in MessageDraft.
if recipients["reply_to"]:
message["Reply-To"] = _format_recipient_header(recipients["reply_to"])
if recipients["disposition_notification_to"]:
message["Disposition-Notification-To"] = _format_recipient_header(recipients["disposition_notification_to"])
# bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender.
message["Subject"] = subject
if html_body is not None:
message.set_content(text_body or "")
message.add_alternative(html_body, subtype="html")
else:
message.set_content(text_body or "")
if work_dir is None:
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-"))
attachment_count = _attach_files(
message=message,
config=config,
entry=entry,
entry_index=entry_index,
resolution=resolution,
values=values,
work_dir=work_dir,
)
build_status = BuildStatus.BUILT
except ZipBuildError as exc:
issues.append(MessageIssue(severity="error", code=exc.code, message=str(exc), behavior="block", source="attachments"))
validation_status = MessageValidationStatus.BLOCKED
build_status = BuildStatus.BUILD_FAILED
attachment_count = 0
message = None # type: ignore[assignment]
except Exception as exc:
issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder"))
validation_status = MessageValidationStatus.BLOCKED
build_status = BuildStatus.BUILD_FAILED
attachment_count = 0
message = None # type: ignore[assignment]
eml_path: str | None = None
eml_size: int | None = None
if write_eml and output_dir is not None and message is not None:
eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index)
draft = MessageDraft(
entry_index=entry_index,
entry_id=entry.id,
active=entry.active,
build_status=build_status,
validation_status=validation_status,
send_status=SendStatus.DRAFT,
imap_status=_imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED,
subject=subject,
from_=_message_address(sender),
from_all=_message_addresses(senders),
to=_message_addresses(recipients["to"]),
cc=_message_addresses(recipients["cc"]),
bcc=_message_addresses(recipients["bcc"]),
reply_to=_message_addresses(recipients["reply_to"]),
bounce_to=_message_addresses(recipients["bounce_to"]),
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
attachment_count=attachment_count,
attachments=_attachment_summaries(resolution),
issues=issues,
eml_path=eml_path,
eml_size_bytes=eml_size,
)
return BuiltMessage(draft=draft, mime=message)
def _unsent_attachment_issues(
*,
config: CampaignConfig,
campaign_file: str | Path,
built_messages: list[BuiltMessage],
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if behavior == Behavior.CONTINUE.value:
return []
matched_files = {
Path(match).resolve()
for built in built_messages
for attachment in built.draft.attachments
for match in attachment.matches
}
issues: list[MessageIssue] = []
for base_path in config.attachments.base_paths:
if not base_path.unsent_warning:
continue
directory = _resolve(campaign_file, base_path.path)
if not directory.exists() or not directory.is_dir():
continue
all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file())
unsent = [path for path in all_files if path not in matched_files]
if not unsent:
continue
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
if len(unsent) > 10:
shown += f", … (+{len(unsent) - 10} more)"
issues.append(
_issue_from_behavior(
code="unsent_attachment_files",
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
behavior=behavior,
source=f"attachments:{base_path.name}",
)
)
return issues
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
if not issues:
return
for built in built_messages:
if not built.draft.active:
continue
built.draft.issues.extend(issues)
status = built.draft.validation_status
for issue in issues:
if issue.behavior:
status = _apply_behavior(status, issue.behavior)
built.draft.validation_status = status
def build_campaign_messages(
config: CampaignConfig,
*,
campaign_file: str | Path,
output_dir: str | Path | None = None,
write_eml: bool = False,
) -> CampaignBuildResult:
campaign_path = Path(campaign_file).resolve()
entries = load_campaign_entries(config, campaign_file=campaign_path)
output_path = Path(output_dir).resolve() if output_dir is not None else None
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
work_dir = output_path or Path(tmp)
built_messages = [
build_entry_message(
config=config,
campaign_file=campaign_path,
entry=entry,
entry_index=index,
output_dir=output_path,
write_eml=write_eml,
work_dir=work_dir,
)
for index, entry in enumerate(entries, start=1)
if entry.active
]
_apply_campaign_level_issues(
built_messages,
_unsent_attachment_issues(config=config, campaign_file=campaign_path, built_messages=built_messages),
)
report = CampaignBuildReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
campaign_file=str(campaign_path),
entries_count=len(entries),
inactive_entries_count=sum(1 for entry in entries if not entry.active),
messages=[built.draft for built in built_messages],
)
return CampaignBuildResult(report=report, built_messages=built_messages)

View File

@@ -0,0 +1,147 @@
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from govoplan_campaign.backend.campaign.models import BuildStatus, SendStatus
class MessageValidationStatus(StrEnum):
READY = "ready"
WARNING = "warning"
NEEDS_REVIEW = "needs_review"
BLOCKED = "blocked"
EXCLUDED = "excluded"
INACTIVE = "inactive"
class ImapStatus(StrEnum):
NOT_REQUESTED = "not_requested"
PENDING = "pending"
APPENDED = "appended"
FAILED = "failed"
SKIPPED = "skipped"
class MessageIssue(BaseModel):
model_config = ConfigDict(extra="forbid")
severity: Literal["info", "warning", "error"]
code: str
message: str
behavior: str | None = None
source: str | None = None
class MessageAddress(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
name: str | None = None
class MessageAttachmentSummary(BaseModel):
model_config = ConfigDict(extra="forbid")
attachment_id: str | None = None
label: str | None = None
status: str
behavior: str | None = None
required: bool
allow_multiple: bool
zip_enabled: bool
zip_mode: str = "inherit"
zip_archive_id: str | None = None
zip_filename: str | None = None
base_path_name: str | None = None
base_path: str | None = None
file_filter: str
directory: str
matches: list[str] = Field(default_factory=list)
managed_matches: list[dict[str, object]] = Field(default_factory=list)
class MessageDraft(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
entry_index: int
entry_id: str | None = None
active: bool
build_status: BuildStatus
validation_status: MessageValidationStatus
send_status: SendStatus
imap_status: ImapStatus
subject: str | None = None
from_: MessageAddress | None = Field(default=None, alias="from")
from_all: list[MessageAddress] = Field(default_factory=list)
to: list[MessageAddress] = Field(default_factory=list)
cc: list[MessageAddress] = Field(default_factory=list)
bcc: list[MessageAddress] = Field(default_factory=list)
reply_to: list[MessageAddress] = Field(default_factory=list)
bounce_to: list[MessageAddress] = Field(default_factory=list)
disposition_notification_to: list[MessageAddress] = Field(default_factory=list)
attachment_count: int = 0
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
issues: list[MessageIssue] = Field(default_factory=list)
eml_path: str | None = None
eml_size_bytes: int | None = None
@property
def is_queueable(self) -> bool:
return self.active and self.build_status == BuildStatus.BUILT and self.validation_status in {
MessageValidationStatus.READY,
MessageValidationStatus.WARNING,
}
class CampaignBuildReport(BaseModel):
model_config = ConfigDict(extra="forbid")
campaign_id: str
campaign_name: str
campaign_file: str
entries_count: int
inactive_entries_count: int = 0
messages: list[MessageDraft] = Field(default_factory=list)
@property
def built_count(self) -> int:
return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILT)
@property
def build_failed_count(self) -> int:
return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILD_FAILED)
@property
def ready_count(self) -> int:
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.READY)
@property
def warning_count(self) -> int:
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.WARNING)
@property
def needs_review_count(self) -> int:
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.NEEDS_REVIEW)
@property
def blocked_count(self) -> int:
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.BLOCKED)
@property
def excluded_count(self) -> int:
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.EXCLUDED)
@property
def inactive_count(self) -> int:
return self.inactive_entries_count
@property
def queueable_count(self) -> int:
return sum(1 for message in self.messages if message.is_queueable)

View File

@@ -0,0 +1,508 @@
from __future__ import annotations
import hashlib
import json
from email import policy
from email.parser import BytesParser
from pathlib import Path
from typing import Any
import copy
from datetime import UTC, datetime
from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignStatus,
CampaignVersion,
CampaignVersionWorkflowState,
JobImapStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
)
from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json, validate_against_schema
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_mail.backend.mail_profiles import materialize_campaign_mail_profile_config
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
prepared_campaign_snapshot,
public_attachment_summary_payload,
)
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
BUILD_OUTPUT_DIR = RUNTIME_DIR / "generated_eml"
class CampaignPersistenceError(RuntimeError):
pass
def _ensure_dirs() -> None:
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
def load_campaign_config_from_json(
session: Session,
*,
tenant_id: str,
raw_json: dict[str, Any],
campaign_id: str | None = None,
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> CampaignConfig:
materialized = materialize_campaign_mail_profile_config(
session,
tenant_id=tenant_id,
raw_json=raw_json,
campaign_id=campaign_id,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
validate_against_schema(materialized)
return CampaignConfig.model_validate(materialized)
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
_ensure_dirs()
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
return path
def _next_version_number(session: Session, campaign_id: str) -> int:
current = session.query(func.max(CampaignVersion.version_number)).filter(CampaignVersion.campaign_id == campaign_id).scalar()
return int(current or 0) + 1
def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | None:
if not value or base_path is None:
return value
path = Path(value).expanduser()
if path.is_absolute():
return str(path)
return str((base_path / path).resolve())
def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]:
"""Return a DB/runtime-safe campaign JSON snapshot.
The CLI naturally resolves relative paths against the campaign.json file.
Once the campaign is stored in the database, the JSON snapshot lives in
app/mailer/runtime/campaign_snapshots. To keep existing file-based
campaigns working, relative file paths are normalized to absolute paths at
import time when a source_base_path is known.
"""
base = Path(source_base_path).expanduser().resolve() if source_base_path else None
data = copy.deepcopy(raw_json)
template_source = data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None
if isinstance(template_source, dict):
for key in ("subject_path", "text_path", "html_path"):
template_source[key] = _resolve_runtime_path(base, template_source.get(key))
entries_source = data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None
if isinstance(entries_source, dict):
entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path"))
attachments = data.get("attachments")
if isinstance(attachments, dict):
attachments["base_path"] = _resolve_runtime_path(base, attachments.get("base_path")) or "."
return data
def create_campaign_version_from_json(
session: Session,
*,
tenant_id: str,
user_id: str | None,
raw_json: dict[str, Any],
source_filename: str | None = None,
source_base_path: str | None = None,
) -> tuple[Campaign, CampaignVersion]:
if source_base_path is None and source_filename:
source_path = Path(source_filename).expanduser()
source_base_path = str(source_path.parent if source_path.suffix else source_path)
runtime_json = normalize_campaign_paths(raw_json, source_base_path)
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id)
campaign = (
session.query(Campaign)
.filter(Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id)
.one_or_none()
)
if campaign is None:
campaign = Campaign(
tenant_id=tenant_id,
created_by_user_id=user_id,
owner_user_id=user_id,
external_id=config.campaign.id,
name=config.campaign.name,
description=config.campaign.description,
status=CampaignStatus.DRAFT.value,
)
session.add(campaign)
session.flush()
else:
current = session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None
if current and not _version_is_audit_safe_snapshot(current):
raise CampaignPersistenceError(
f"Campaign already has active working version #{current.version_number}. "
"Continue editing or unlock that version instead of importing a parallel draft."
)
campaign.name = config.campaign.name
campaign.description = config.campaign.description
version = CampaignVersion(
campaign_id=campaign.id,
version_number=_next_version_number(session, campaign.id),
raw_json=runtime_json,
schema_version=raw_json.get("version", "1.0"),
source_filename=source_filename,
source_base_path=source_base_path,
)
session.add(version)
session.flush()
campaign.current_version_id = version.id
session.add(campaign)
_write_campaign_snapshot(version)
session.commit()
return campaign, version
def _version_user_lock_state(version: CampaignVersion) -> str | None:
state = getattr(version, "user_lock_state", None)
if state in {"temporary", "permanent"}:
return state
return "permanent" if version.published_at else None
def _version_is_user_locked(version: CampaignVersion) -> bool:
return _version_user_lock_state(version) is not None
def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool:
return _version_user_lock_state(version) == "permanent" or version.workflow_state in {
CampaignVersionWorkflowState.QUEUED.value,
CampaignVersionWorkflowState.SENDING.value,
CampaignVersionWorkflowState.COMPLETED.value,
CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value,
CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value,
CampaignVersionWorkflowState.FAILED.value,
CampaignVersionWorkflowState.CANCELLED.value,
CampaignVersionWorkflowState.ARCHIVED.value,
}
def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersion, *, action: str) -> None:
if campaign.current_version_id != version.id:
raise CampaignPersistenceError(
f"Historical campaign versions are read-only and cannot be used to {action}. "
"Open the current working version instead."
)
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
return bool(version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version))
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
state = _version_user_lock_state(version)
if state == "temporary":
raise CampaignPersistenceError("This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending.")
if state == "permanent":
raise CampaignPersistenceError("This version is permanently user-locked. Create an editable copy instead.")
if not _version_is_validated_and_locked(version):
raise CampaignPersistenceError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
def load_version_config(session: Session, version_id: str):
version = session.get(CampaignVersion, version_id)
if not version:
raise CampaignPersistenceError(f"Campaign version not found: {version_id}")
campaign = session.get(Campaign, version.campaign_id)
if not campaign:
raise CampaignPersistenceError(f"Campaign not found for version: {version_id}")
path = _write_campaign_snapshot(version)
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id)
def validate_campaign_version(
session: Session,
*,
tenant_id: str,
version_id: str,
check_files: bool = False,
user_id: str | None = None,
lock_on_success: bool = True,
) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
_ensure_current_campaign_version(campaign, version, action="validate")
if _version_is_user_locked(version) or version.workflow_state in {
CampaignVersionWorkflowState.QUEUED.value,
CampaignVersionWorkflowState.SENDING.value,
CampaignVersionWorkflowState.COMPLETED.value,
CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value,
CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value,
CampaignVersionWorkflowState.FAILED.value,
CampaignVersionWorkflowState.CANCELLED.value,
CampaignVersionWorkflowState.ARCHIVED.value,
}:
lock_label = "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final"
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
if check_files:
with prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=False,
prefix="multimailer-managed-validate-",
) as prepared:
managed_raw = load_campaign_json(prepared.path)
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
report = validate_campaign_config(managed_config, campaign_file=prepared.path, check_files=True)
else:
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=False)
report_json = report.model_dump(mode="json")
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
version.validation_summary = report_json
# Replace version-level semantic issues from previous validations.
(
session.query(CampaignIssue)
.filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None))
.delete(synchronize_session=False)
)
for issue in report.issues:
session.add(
CampaignIssue(
tenant_id=tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
severity=issue.severity.value,
code=issue.code,
message=issue.message,
source=issue.path,
)
)
campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value
if report.ok:
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
version.is_complete = True
if lock_on_success and version.locked_at is None:
from datetime import UTC, datetime
version.locked_at = datetime.now(UTC)
version.locked_by_user_id = user_id
else:
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
session.add(version)
session.add(campaign)
session.commit()
return report_json
def _job_validation_status(value: str) -> str:
allowed = {item.value for item in JobValidationStatus}
return value if value in allowed else JobValidationStatus.NEEDS_REVIEW.value
def _eml_evidence(eml_path: str | None) -> tuple[str | None, str | None]:
if not eml_path:
return None, None
path = Path(eml_path)
if not path.exists():
return None, None
payload = path.read_bytes()
message_id = BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID")
return hashlib.sha256(payload).hexdigest(), str(message_id) if message_id else None
def _job_from_message(
*,
tenant_id: str,
campaign_id: str,
version_id: str,
message: MessageDraft,
) -> CampaignJob:
recipient_email = message.to[0].email if message.to else None
eml_sha256, message_id_header = _eml_evidence(message.eml_path)
return CampaignJob(
tenant_id=tenant_id,
campaign_id=campaign_id,
campaign_version_id=version_id,
entry_index=message.entry_index,
entry_id=message.entry_id,
recipient_email=recipient_email,
subject=message.subject,
message_id_header=message_id_header,
eml_local_path=message.eml_path,
eml_size_bytes=message.eml_size_bytes,
eml_sha256=eml_sha256,
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
validation_status=_job_validation_status(message.validation_status.value),
queue_status=JobQueueStatus.DRAFT.value,
send_status=JobSendStatus.NOT_QUEUED.value,
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
resolved_recipients={
"from": message.from_.model_dump(mode="json") if message.from_ else None,
"from_all": [item.model_dump(mode="json") for item in message.from_all],
"to": [item.model_dump(mode="json") for item in message.to],
"cc": [item.model_dump(mode="json") for item in message.cc],
"bcc": [item.model_dump(mode="json") for item in message.bcc],
"reply_to": [item.model_dump(mode="json") for item in message.reply_to],
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
},
resolved_attachments=[public_attachment_summary_payload(item) for item in message.attachments],
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
)
def build_campaign_version(
session: Session,
*,
tenant_id: str,
version_id: str,
write_eml: bool = True,
) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
_ensure_current_campaign_version(campaign, version, action="build")
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
if not validation_summary.get("ok"):
raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built")
_ensure_version_validated_and_locked(version)
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
with prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-managed-build-",
) as prepared:
managed_raw = load_campaign_json(prepared.path)
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
report_json = result.report.model_dump(mode="json", by_alias=True)
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
if isinstance(message_payload, dict):
message_payload["attachments"] = [public_attachment_summary_payload(item) for item in message.attachments]
report_json["built_at"] = datetime.now(UTC).isoformat()
report_json["build_token"] = uuid4().hex
report_json.update({
"built_count": result.report.built_count,
"build_failed_count": result.report.build_failed_count,
"ready_count": result.report.ready_count,
"warning_count": result.report.warning_count,
"needs_review_count": result.report.needs_review_count,
"blocked_count": result.report.blocked_count,
"excluded_count": result.report.excluded_count,
"inactive_count": result.report.inactive_count,
"queueable_count": result.report.queueable_count,
})
version.build_summary = report_json
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
version.editor_state = editor_state
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False)
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
session.flush()
job_build_pairs: list[tuple[CampaignJob, MessageDraft]] = []
for built in result.built_messages:
job = _job_from_message(
tenant_id=tenant_id,
campaign_id=campaign.id,
version_id=version.id,
message=built.draft,
)
session.add(job)
job_build_pairs.append((job, built.draft))
# Assign all job IDs in one round-trip, then persist exact attachment use
# records in bulk. This avoids one flush plus several metadata queries per
# recipient for large campaigns.
session.flush()
record_campaign_attachment_uses_for_jobs(
session,
[job for job, _message in job_build_pairs],
stage="built",
)
if not managed_config.server.smtp:
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
version,
smtp=managed_config.server.smtp,
imap=managed_config.server.imap,
delivery=managed_config.delivery,
jobs=[job for job, _message in job_build_pairs],
build_summary=report_json,
)
version.execution_snapshot = execution_snapshot
version.execution_snapshot_hash = execution_snapshot_hash
version.execution_snapshot_at = datetime.now(UTC)
for job, message in job_build_pairs:
for issue in message.issues:
session.add(
CampaignIssue(
tenant_id=tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
job_id=job.id,
severity=issue.severity,
code=issue.code,
message=issue.message,
source=issue.source,
behavior=issue.behavior,
)
)
if result.report.needs_review_count or result.report.blocked_count:
campaign.status = CampaignStatus.NEEDS_REVIEW.value
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
elif result.report.queueable_count > 0:
campaign.status = CampaignStatus.READY_TO_QUEUE.value
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
else:
campaign.status = CampaignStatus.VALIDATED.value
session.add(version)
session.add(campaign)
session.commit()
return report_json

View File

@@ -0,0 +1,827 @@
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignStatus,
CampaignVersion,
CampaignVersionFlow,
CampaignVersionWorkflowState,
CampaignJob,
JobSendStatus,
)
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
from govoplan_mail.backend.mail_profiles import assert_campaign_mail_policy_allows_json
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_next_version_number,
_write_campaign_snapshot,
normalize_campaign_paths,
)
class LockedCampaignVersionError(CampaignPersistenceError):
"""Raised when a caller tries to edit an immutable campaign version."""
USER_LOCK_TEMPORARY = "temporary"
USER_LOCK_PERMANENT = "permanent"
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
def campaign_version_user_lock_state(version: CampaignVersion) -> str | None:
"""Return the explicit user-lock state with backwards compatibility.
Older databases represented a permanent user lock only through
published_at. Treat those rows as permanent until the migration has
backfilled the explicit state.
"""
state = getattr(version, "user_lock_state", None)
if state in USER_LOCK_STATES:
return state
if version.published_at:
return USER_LOCK_PERMANENT
return None
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
"""Return a WebUI-friendly starter campaign JSON.
It is intentionally usable as an editable working copy. It contains the
main sections the UI expects, but it may still be incomplete from the
strict send/build perspective until the user configures recipients,
template and sender details.
"""
return {
"version": "1.0",
"campaign": {
"id": external_id,
"name": name,
"description": description or "",
"mode": "draft",
},
"fields": [],
"global_values": {},
"server": {
"inherit_smtp_credentials": True,
"inherit_imap_credentials": True,
"smtp": {
"host": "",
"port": 587,
"username": "",
"password": "",
"security": "starttls",
},
"imap": {
"enabled": False,
"host": "",
"port": 993,
"username": "",
"password": "",
"security": "tls",
"sent_folder": "auto",
},
},
"recipients": {
"from": [],
"allow_individual_from": False,
"to": [],
"allow_individual_to": True,
"cc": [],
"allow_individual_cc": False,
"bcc": [],
"allow_individual_bcc": False,
"reply_to": [],
"allow_individual_reply_to": False,
"bounce_to": [],
"allow_individual_bounce_to": False,
"disposition_notification_to": [],
"allow_individual_disposition_notification_to": False,
},
"template": {
"subject": "",
"text": "",
"html": None,
},
"attachments": {
"base_path": ".",
"base_paths": [
{
"id": "default",
"name": "Campaign files",
"path": ".",
"allow_individual": True,
"unsent_warning": False,
}
],
"allow_individual": True,
"send_without_attachments": False,
"global": [],
"zip": {"enabled": False, "archives": []},
"missing_behavior": "ask",
"ambiguous_behavior": "ask",
},
"entries": {
"inline": [],
"defaults": {
"active": True,
"merge_to": False,
"merge_cc": True,
"merge_bcc": True,
"merge_reply_to": True,
"merge_bounce_to": True,
"merge_disposition_notification_to": True,
"combine_attachments": True,
"attachments": [],
},
},
"validation_policy": {
"missing_required_attachment": "ask",
"missing_optional_attachment": "warn",
"ambiguous_attachment_match": "ask",
"unsent_attachment_files": "warn",
"ignore_empty_fields": False,
"missing_email": "block",
"template_error": "block",
},
"delivery": {
"rate_limit": {
"messages_per_minute": 5,
"concurrency": 1,
},
"imap_append_sent": {
"enabled": False,
"folder": "auto",
},
"retry": {
"max_attempts": 3,
"backoff_seconds": [60, 300, 900],
},
},
"status_tracking": {
"enabled": True,
},
}
def create_minimal_campaign(
session: Session,
*,
tenant_id: str,
user_id: str | None,
external_id: str,
name: str,
description: str | None = None,
current_flow: str = CampaignVersionFlow.CREATE.value,
current_step: str = "basics",
) -> tuple[Campaign, CampaignVersion]:
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
if existing:
raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant")
campaign = Campaign(
tenant_id=tenant_id,
created_by_user_id=user_id,
owner_user_id=user_id,
external_id=external_id,
name=name,
description=description,
status=CampaignStatus.DRAFT.value,
)
session.add(campaign)
session.flush()
version = CampaignVersion(
campaign_id=campaign.id,
version_number=1,
raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description),
schema_version="1.0",
workflow_state=CampaignVersionWorkflowState.EDITING.value,
current_flow=current_flow,
current_step=current_step,
is_complete=False,
editor_state={"created_from": "minimal_campaign"},
autosaved_at=datetime.now(UTC),
)
session.add(version)
session.flush()
campaign.current_version_id = version.id
session.add(campaign)
_write_campaign_snapshot(version)
session.commit()
return campaign, version
def get_campaign_version_for_tenant(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
) -> CampaignVersion:
campaign = session.get(Campaign, campaign_id)
version = session.get(CampaignVersion, version_id)
if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id:
raise CampaignPersistenceError("Campaign version not found")
return version
LOCKED_WORKFLOW_STATES = {
CampaignVersionWorkflowState.APPROVED.value,
CampaignVersionWorkflowState.BUILT.value,
CampaignVersionWorkflowState.QUEUED.value,
CampaignVersionWorkflowState.SENDING.value,
CampaignVersionWorkflowState.COMPLETED.value,
CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value,
CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value,
CampaignVersionWorkflowState.FAILED.value,
CampaignVersionWorkflowState.CANCELLED.value,
CampaignVersionWorkflowState.ARCHIVED.value,
}
def is_version_locked(version: CampaignVersion) -> bool:
"""Return True when a version is immutable and edits must fork/unlock."""
return bool(
version.locked_at
or campaign_version_user_lock_state(version)
or version.workflow_state in LOCKED_WORKFLOW_STATES
)
def ensure_current_working_version(campaign: Campaign, version: CampaignVersion, *, action: str = "modify") -> None:
"""Require the campaign's single active working version.
Historical versions remain reviewable, but they never become writable in
place. Continuing from immutable history must create a new working copy,
and that copy becomes the campaign's sole current version.
"""
if campaign.current_version_id != version.id:
raise LockedCampaignVersionError(
f"Historical campaign versions are read-only and cannot be used to {action}. "
"Open the current working version instead."
)
def campaign_has_active_working_version(session: Session, campaign: Campaign) -> bool:
"""Return True while the campaign already has a non-final working version.
Validation locks and temporary user locks are still the same working
version; they must be unlocked rather than forked into parallel drafts.
"""
if not campaign.current_version_id:
return False
current = session.get(CampaignVersion, campaign.current_version_id)
if not current or current.campaign_id != campaign.id:
return False
return not is_audit_safe_version(current)
def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None:
campaign_meta = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
if campaign_meta:
campaign.name = campaign_meta.get("name") or campaign.name
campaign.description = campaign_meta.get("description", campaign.description)
campaign.external_id = campaign_meta.get("id") or campaign.external_id
def fork_campaign_version_for_edit(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
raw_json: dict[str, Any] | None = None,
current_flow: str | None = None,
current_step: str | None = None,
editor_state: dict[str, Any] | None = None,
source_filename: str | None = None,
source_base_path: str | None = None,
autosave: bool = True,
) -> CampaignVersion:
"""Create the next sole working version from immutable campaign history.
Validation and temporary user locks are still the active working version
and must be unlocked in place. A copy is allowed only once the current
version is permanently user-locked or delivery-final.
"""
source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
if campaign_has_active_working_version(session, campaign):
current = session.get(CampaignVersion, campaign.current_version_id)
current_number = current.version_number if current else "current"
raise LockedCampaignVersionError(
f"Campaign already has active working version #{current_number}. "
"Unlock or continue editing that version instead of creating a parallel draft."
)
if campaign.current_version_id and source.id != campaign.current_version_id:
raise LockedCampaignVersionError(
"Historical versions remain review-only and cannot become a new branch. "
"Create the next working copy from the campaign's current immutable version."
)
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
new_version = CampaignVersion(
campaign_id=campaign.id,
version_number=_next_version_number(session, campaign.id),
raw_json=runtime_json,
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
source_filename=source_filename if source_filename is not None else source.source_filename,
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
workflow_state=CampaignVersionWorkflowState.EDITING.value,
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
current_step=current_step if current_step is not None else source.current_step,
is_complete=False,
editor_state=editor_state if editor_state is not None else copy.deepcopy(source.editor_state or {}),
autosaved_at=datetime.now(UTC) if autosave else None,
)
session.add(new_version)
session.flush()
_apply_campaign_metadata(campaign, runtime_json)
campaign.current_version_id = new_version.id
campaign.status = CampaignStatus.DRAFT.value
session.add(campaign)
_write_campaign_snapshot(new_version)
session.commit()
return new_version
def lock_validated_version(version: CampaignVersion, *, user_id: str | None = None) -> None:
if version.locked_at is None:
version.locked_at = datetime.now(UTC)
version.locked_by_user_id = user_id
def is_version_final_locked(version: CampaignVersion) -> bool:
"""Return True when a version is part of or past delivery and must stay immutable."""
return version.workflow_state in {
CampaignVersionWorkflowState.QUEUED.value,
CampaignVersionWorkflowState.SENDING.value,
CampaignVersionWorkflowState.COMPLETED.value,
CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value,
CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value,
CampaignVersionWorkflowState.FAILED.value,
CampaignVersionWorkflowState.CANCELLED.value,
CampaignVersionWorkflowState.ARCHIVED.value,
}
def is_temporary_user_locked_version(version: CampaignVersion) -> bool:
return campaign_version_user_lock_state(version) == USER_LOCK_TEMPORARY
def is_permanent_user_locked_version(version: CampaignVersion) -> bool:
return campaign_version_user_lock_state(version) == USER_LOCK_PERMANENT
def is_user_locked_version(version: CampaignVersion) -> bool:
"""Return True for either reversible or permanent user-requested locks."""
return campaign_version_user_lock_state(version) is not None
def is_audit_safe_version(version: CampaignVersion) -> bool:
"""Return True when a version is immutable and cannot be unlocked."""
return is_permanent_user_locked_version(version) or is_version_final_locked(version)
def is_version_validated_and_locked(version: CampaignVersion) -> bool:
"""Return True when the version was successfully validated and locked as a review snapshot."""
validation = version.validation_summary if isinstance(version.validation_summary, dict) else {}
return bool(version.locked_at and validation.get("ok") is True)
def unlock_validated_campaign_version(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
) -> CampaignVersion:
"""Unlock a validation snapshot so it can be edited again.
This is only allowed before delivery starts. Unlocking invalidates validation,
build output and queued job records for that version. Sent/final versions must
be copied instead.
"""
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="unlock")
if is_temporary_user_locked_version(version):
raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.")
if is_permanent_user_locked_version(version):
raise LockedCampaignVersionError("This version is permanently locked and cannot be unlocked. Create an editable copy instead.")
if is_version_final_locked(version):
raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.")
# A version with sent jobs is final even if workflow_state was not updated for some reason.
sent_jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.campaign_version_id == version.id,
CampaignJob.send_status.in_([JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value]),
)
.count()
)
if sent_jobs:
raise LockedCampaignVersionError("This version has sent messages and cannot be unlocked. Create an editable copy instead.")
version.locked_at = None
version.locked_by_user_id = None
version.validation_summary = None
version.build_summary = None
clear_execution_snapshot(version)
editor_state = copy.deepcopy(version.editor_state or {})
editor_state.pop("review_send", None)
version.editor_state = editor_state
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
version.is_complete = False
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
campaign.current_version_id = version.id
campaign.status = CampaignStatus.DRAFT.value
session.add(version)
session.add(campaign)
session.commit()
return version
def update_campaign_version(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
raw_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,
autosave: bool = False,
) -> CampaignVersion:
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="edit")
if is_version_locked(version):
raise LockedCampaignVersionError(
"Campaign version is locked. Create an editable copy before changing campaign data."
)
if raw_json is not None:
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
version.raw_json = runtime_json
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
_apply_campaign_metadata(campaign, runtime_json)
if current_flow is not None:
version.current_flow = current_flow
if current_step is not None:
version.current_step = current_step
if workflow_state is not None:
version.workflow_state = workflow_state
if is_complete is not None:
version.is_complete = is_complete
if editor_state is not None:
version.editor_state = editor_state
if source_filename is not None:
version.source_filename = source_filename
if source_base_path is not None:
version.source_base_path = source_base_path
if autosave:
version.autosaved_at = datetime.now(UTC)
# Changes invalidate previous build and validation summaries.
if raw_json is not None:
version.validation_summary = None
version.build_summary = None
clear_execution_snapshot(version)
version.locked_at = None
version.locked_by_user_id = None
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
campaign.status = CampaignStatus.DRAFT.value
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
session.add(version)
session.add(campaign)
session.flush()
_write_campaign_snapshot(version)
session.commit()
return version
def update_campaign_review_state(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
inspection_complete: bool,
reviewed_message_keys: list[str],
user_id: str | None,
) -> CampaignVersion:
"""Persist review acknowledgement without mutating the locked campaign data.
Validation locks make the campaign JSON immutable, but review metadata is
operational state attached to a specific build. It is therefore stored in
editor_state and tied to the current build token so a rebuild invalidates it.
"""
version = get_campaign_version_for_tenant(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="record review state for")
if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
if not build_summary:
raise CampaignPersistenceError("Build messages before recording review state.")
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
if not build_token:
# Backwards-compatible upgrade for build summaries created before
# review-state tokens were introduced.
build_token = uuid4().hex
build_summary = copy.deepcopy(build_summary)
build_summary["build_token"] = build_token
version.build_summary = build_summary
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
if inspection_complete:
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
.order_by(CampaignJob.entry_index.asc())
.all()
)
blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"]
if blocking:
raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.")
reviewed = set(normalized_reviewed)
explicit = {
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status == "needs_review"
}
missing = sorted(explicit - reviewed)
if missing:
raise CampaignPersistenceError(
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
)
bulk_acceptable = [
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status in {"warning", "excluded"}
]
normalized_reviewed = list(dict.fromkeys([*normalized_reviewed, *bulk_acceptable]))
editor_state = copy.deepcopy(version.editor_state or {})
editor_state["review_send"] = {
"build_token": build_token,
"inspection_complete": bool(inspection_complete),
"reviewed_message_keys": normalized_reviewed,
"updated_at": datetime.now(UTC).isoformat(),
"updated_by_user_id": user_id,
}
version.editor_state = editor_state
session.add(version)
session.commit()
return version
def lock_campaign_version_temporarily(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
user_id: str | None,
) -> CampaignVersion:
"""Apply a reversible user-requested lock without changing workflow state."""
version = get_campaign_version_for_tenant(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="lock")
if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
if is_permanent_user_locked_version(version):
raise LockedCampaignVersionError("This version is already permanently locked.")
if is_temporary_user_locked_version(version):
return version
if version.locked_at:
raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.")
version.user_lock_state = USER_LOCK_TEMPORARY
version.user_locked_at = datetime.now(UTC)
version.user_locked_by_user_id = user_id
session.add(version)
session.commit()
return version
def unlock_user_locked_campaign_version(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
) -> CampaignVersion:
"""Remove a reversible user lock without invalidating campaign data."""
version = get_campaign_version_for_tenant(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="unlock")
state = campaign_version_user_lock_state(version)
if state == USER_LOCK_PERMANENT:
raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.")
if state != USER_LOCK_TEMPORARY:
raise LockedCampaignVersionError("This version does not have a temporary user lock.")
if is_version_final_locked(version):
raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.")
version.user_lock_state = None
version.user_locked_at = None
version.user_locked_by_user_id = None
session.add(version)
session.commit()
return version
def permanently_lock_campaign_version(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
user_id: str | None,
) -> CampaignVersion:
"""Apply an irreversible user lock.
The version remains in its current workflow state so the campaign itself is
not silently archived. Future changes must be made in an editable copy.
"""
version = get_campaign_version_for_tenant(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
ensure_current_working_version(campaign, version, action="lock permanently")
if is_version_final_locked(version):
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
if is_permanent_user_locked_version(version):
return version
now = datetime.now(UTC)
version.user_lock_state = USER_LOCK_PERMANENT
version.user_locked_at = now
version.user_locked_by_user_id = user_id
# Retain published_at as a compatibility marker for existing integrations.
version.published_at = version.published_at or now
session.add(version)
session.commit()
return version
def publish_campaign_version(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str,
user_id: str | None = None,
) -> CampaignVersion:
"""Backwards-compatible alias for the permanent user lock."""
return permanently_lock_campaign_version(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version_id,
user_id=user_id,
)
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
"""Lightweight UI-facing validation for incomplete campaign working copies.
This is intentionally less strict than campaign.schema.json validation. It
lets the WebUI autosave and validate one wizard step at a time.
"""
issues: list[dict[str, Any]] = []
def issue(severity: str, sec: str, field: str, code: str, message: str) -> None:
if section is None or section == sec:
issues.append({
"severity": severity,
"section": sec,
"field": field,
"code": code,
"message": message,
})
campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
if not campaign.get("id"):
issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
if not campaign.get("name"):
issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {}
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
if not sender.get("email"):
issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {}
has_inline = bool(entries.get("inline"))
has_source = isinstance(entries.get("source"), dict)
if not has_inline and not has_source:
issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
if has_source:
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
if not any(key in mapping for key in ("to.0.email", "to.email", "email")):
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")):
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict):
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths)
if not has_named_base_path and not attachments.get("base_path"):
issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {}
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
messages_per_minute = rate_limit.get("messages_per_minute")
if messages_per_minute is not None:
try:
if int(messages_per_minute) < 1:
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
except (TypeError, ValueError):
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
return {
"ok": not any(item["severity"] == "error" for item in issues),
"section": section,
"error_count": sum(1 for item in issues if item["severity"] == "error"),
"warning_count": sum(1 for item in issues if item["severity"] == "warning"),
"info_count": sum(1 for item in issues if item["severity"] == "info"),
"issues": issues,
}

View File

@@ -0,0 +1 @@
"""Reporting helpers for campaigns and jobs."""

View File

@@ -0,0 +1,429 @@
from __future__ import annotations
import csv
import io
import math
from collections import Counter
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignVersion,
ImapAppendAttempt,
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
class CampaignReportError(RuntimeError):
pass
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _counter(values: list[str | None]) -> dict[str, int]:
return dict(Counter(value or "unknown" for value in values))
def _get_campaign(session: Session, *, tenant_id: str, campaign_id: str) -> Campaign:
campaign = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.id == campaign_id).one_or_none()
if not campaign:
raise CampaignReportError(f"Campaign not found or not accessible: {campaign_id}")
return campaign
def _selected_version(
session: Session,
campaign: Campaign,
version_id: str | None = None,
) -> CampaignVersion | None:
wanted = version_id or campaign.current_version_id
if not wanted:
return None
version = session.get(CampaignVersion, wanted)
if not version or version.campaign_id != campaign.id:
raise CampaignReportError(f"Campaign version not found or not part of campaign: {wanted}")
return version
def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
if not version:
return None
return {
"id": version.id,
"version_number": version.version_number,
"schema_version": version.schema_version,
"source_filename": version.source_filename,
"created_at": version.created_at.isoformat() if version.created_at else None,
"validation_summary": version.validation_summary,
"build_summary": version.build_summary,
"execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
}
def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, Any]:
"""Read deterministic delivery settings from the immutable execution snapshot."""
default = {
"rate_limit": {"messages_per_minute": None, "concurrency": None},
"imap_append_sent": {"enabled": None, "folder": None},
"retry": {"max_attempts": None, "backoff_seconds": []},
"execution_snapshot_hash": None,
"execution_snapshot_at": None,
"snapshot_version": None,
"build_token": None,
"built_at": None,
"job_manifest_sha256": None,
"job_count": 0,
"queueable_job_count": 0,
"effective_policy_sha256": None,
"smtp_config_fingerprint": None,
"imap_config_fingerprint": None,
"estimated_remaining_send_seconds": None,
"estimated_remaining_send_human": None,
}
if not version or not isinstance(version.execution_snapshot, dict):
default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery."
return default
try:
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
except Exception as exc: # pragma: no cover - reporting should remain available
default["load_error"] = str(exc)
return default
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
pending = [job for job in jobs if job.send_status in {"queued", "claimed", "sending"}]
estimated_seconds = None
if messages_per_minute and pending:
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
return {
"rate_limit": {
"messages_per_minute": messages_per_minute,
"concurrency": snapshot.delivery.rate_limit.concurrency,
},
"imap_append_sent": {
"enabled": snapshot.delivery.imap_append_sent.enabled,
"folder": snapshot.delivery.imap_append_sent.folder,
},
"retry": {
"max_attempts": snapshot.delivery.retry.max_attempts,
"backoff_seconds": snapshot.delivery.retry.backoff_seconds,
},
"execution_snapshot_hash": version.execution_snapshot_hash,
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
"snapshot_version": snapshot.snapshot_version,
"build_token": snapshot.build_token,
"built_at": snapshot.built_at,
"job_manifest_sha256": snapshot.job_manifest_sha256,
"job_count": snapshot.job_count,
"queueable_job_count": snapshot.queueable_job_count,
"effective_policy_sha256": snapshot.effective_policy_sha256,
"smtp_config_fingerprint": snapshot.smtp_config_fingerprint,
"imap_config_fingerprint": snapshot.imap_config_fingerprint,
"estimated_remaining_send_seconds": estimated_seconds,
"estimated_remaining_send_human": _human_duration(estimated_seconds),
}
def _human_duration(seconds: int | None) -> str | None:
if seconds is None:
return None
if seconds < 60:
return f"{seconds}s"
minutes, sec = divmod(seconds, 60)
if minutes < 60:
return f"{minutes}m {sec}s" if sec else f"{minutes}m"
hours, minute = divmod(minutes, 60)
return f"{hours}h {minute}m" if minute else f"{hours}h"
def _issue_summary_from_jobs(jobs: list[CampaignJob]) -> dict[str, Any]:
severity_counter: Counter[str] = Counter()
code_counter: Counter[str] = Counter()
behavior_counter: Counter[str] = Counter()
total = 0
for job in jobs:
for issue in job.issues_snapshot or []:
if not isinstance(issue, dict):
continue
total += 1
severity_counter[issue.get("severity") or "unknown"] += 1
code_counter[issue.get("code") or "unknown"] += 1
if issue.get("behavior"):
behavior_counter[issue["behavior"]] += 1
return {
"total": total,
"by_severity": dict(severity_counter),
"by_code": dict(code_counter),
"by_behavior": dict(behavior_counter),
}
def _attachment_summary(jobs: list[CampaignJob]) -> dict[str, Any]:
status_counter: Counter[str] = Counter()
behavior_counter: Counter[str] = Counter()
total_configs = 0
total_matched_files = 0
zip_enabled = 0
missing = 0
ambiguous = 0
for job in jobs:
for attachment in job.resolved_attachments or []:
if not isinstance(attachment, dict):
continue
total_configs += 1
status = attachment.get("status") or "unknown"
status_counter[status] += 1
if attachment.get("behavior"):
behavior_counter[attachment["behavior"]] += 1
matches = attachment.get("matches") or []
if isinstance(matches, list):
total_matched_files += len(matches)
if attachment.get("zip_enabled"):
zip_enabled += 1
if status == "missing":
missing += 1
if status == "ambiguous":
ambiguous += 1
return {
"total_attachment_configs": total_configs,
"total_matched_files": total_matched_files,
"zip_enabled_configs": zip_enabled,
"missing_configs": missing,
"ambiguous_configs": ambiguous,
"by_status": dict(status_counter),
"by_behavior": dict(behavior_counter),
}
def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]:
failed = [job for job in jobs if job.last_error or str(job.send_status).startswith("failed") or job.imap_status == "failed"]
failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True)
return [
{
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"validation_status": job.validation_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
"attempt_count": job.attempt_count,
"last_error": job.last_error,
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
}
for job in failed[:limit]
]
def _job_row(job: CampaignJob) -> dict[str, Any]:
return {
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"subject": job.subject,
"build_status": job.build_status,
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"imap_status": job.imap_status,
"attempt_count": job.attempt_count,
"queued_at": job.queued_at.isoformat() if job.queued_at else None,
"claimed_at": job.claimed_at.isoformat() if job.claimed_at else None,
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None,
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
"last_error": job.last_error,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"issues_count": len(job.issues_snapshot or []),
"attachment_config_count": len(job.resolved_attachments or []),
"matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)),
}
def generate_campaign_report(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
include_jobs: bool = False,
include_recent_failures: bool = True,
) -> dict[str, Any]:
"""Generate a dashboard/report payload for one campaign.
The shape is intentionally web-UI friendly: status counters for cards,
issue/attachment summaries for review panels, and optional job rows for
tables/export.
"""
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id)
jobs_query = session.query(CampaignJob).filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
)
if version:
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
else:
jobs_query = jobs_query.filter(False)
jobs = (
jobs_query
.order_by(CampaignJob.entry_index.asc())
.all()
)
job_ids = [job.id for job in jobs]
send_attempts = session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
imap_attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
issue_query = session.query(CampaignIssue).filter(
CampaignIssue.tenant_id == tenant_id,
CampaignIssue.campaign_id == campaign.id,
)
if version:
issue_query = issue_query.filter(CampaignIssue.campaign_version_id == version.id)
else:
issue_query = issue_query.filter(False)
persisted_issues = issue_query.count()
validation_counts = _counter([job.validation_status for job in jobs])
queue_counts = _counter([job.queue_status for job in jobs])
send_counts = _counter([job.send_status for job in jobs])
imap_counts = _counter([job.imap_status for job in jobs])
build_counts = _counter([job.build_status for job in jobs])
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
needs_attention = sum(
1
for job in jobs
if job.validation_status in {"needs_review", "blocked"}
or job.send_status in {"failed_temporary", "failed_permanent", "outcome_unknown", "claimed", "sending"}
or job.imap_status == "failed"
)
sent = send_counts.get("sent", 0) + send_counts.get("smtp_accepted", 0)
failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0)
outcome_unknown = send_counts.get("outcome_unknown", 0)
not_attempted = send_counts.get("not_queued", 0)
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0)
cancelled = send_counts.get("cancelled", 0)
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
inactive_entries = int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
if inactive_entries:
validation_counts["inactive"] = inactive_entries
report: dict[str, Any] = {
"generated_at": _utcnow_iso(),
"campaign": {
"id": campaign.id,
"external_id": campaign.external_id,
"name": campaign.name,
"description": campaign.description,
"status": campaign.status,
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
},
"current_version": _version_info(version),
"selected_version_id": version.id if version else None,
"cards": {
"jobs_total": len(jobs),
"inactive": inactive_entries,
"queueable": queueable,
"needs_attention": needs_attention,
"sent": sent,
"smtp_accepted": sent,
"failed": failed,
"outcome_unknown": outcome_unknown,
"not_attempted": not_attempted,
"queued_or_active": queued,
"cancelled": cancelled,
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
"imap_appended": imap_counts.get("appended", 0),
"imap_failed": imap_counts.get("failed", 0),
},
"status_counts": {
"build": build_counts,
"validation": validation_counts,
"queue": queue_counts,
"send": send_counts,
"imap": imap_counts,
},
"issues": {
**_issue_summary_from_jobs(jobs),
"persisted_campaign_issue_count": persisted_issues,
},
"attachments": _attachment_summary(jobs),
"attempts": {
"send_attempts": int(send_attempts),
"imap_append_attempts": int(imap_attempts),
},
"delivery": _load_delivery_info(version, jobs),
}
if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs)
if include_jobs:
report["jobs"] = [_job_row(job) for job in jobs]
return report
def generate_jobs_csv(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
) -> str:
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id)
jobs_query = session.query(CampaignJob).filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
)
if version:
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
else:
jobs_query = jobs_query.filter(False)
jobs = (
jobs_query
.order_by(CampaignJob.entry_index.asc())
.all()
)
rows = [_job_row(job) for job in jobs]
fieldnames = [
"job_id",
"entry_index",
"entry_id",
"recipient_email",
"subject",
"build_status",
"validation_status",
"queue_status",
"send_status",
"imap_status",
"attempt_count",
"queued_at",
"claimed_at",
"smtp_started_at",
"outcome_unknown_at",
"sent_at",
"last_error",
"eml_size_bytes",
"eml_sha256",
"issues_count",
"attachment_config_count",
"matched_file_count",
]
buffer = io.StringIO()
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return buffer.getvalue()

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_campaign.backend.campaign.loader import load_campaign_config
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendResult, send_email_message
class CampaignReportEmailError(RuntimeError):
pass
@dataclass(slots=True)
class CampaignReportEmailResult:
campaign_id: str
version_id: str
to: list[str]
subject: str
dry_run: bool
sent: bool
attached_jobs_csv: bool
attached_report_json: bool
smtp_host: str | None = None
smtp_port: int | None = None
accepted_count: int | None = None
def as_dict(self) -> dict[str, Any]:
return {
"campaign_id": self.campaign_id,
"version_id": self.version_id,
"to": self.to,
"subject": self.subject,
"dry_run": self.dry_run,
"sent": self.sent,
"attached_jobs_csv": self.attached_jobs_csv,
"attached_report_json": self.attached_report_json,
"smtp_host": self.smtp_host,
"smtp_port": self.smtp_port,
"accepted_count": self.accepted_count,
}
def _selected_version(
session: Session,
campaign: Campaign,
version_id: str | None = None,
) -> CampaignVersion:
wanted = version_id or campaign.current_version_id
version = session.get(CampaignVersion, wanted) if wanted else None
if version is None or version.campaign_id != campaign.id:
raise CampaignReportEmailError("Campaign version not found")
return version
def _load_config(version: CampaignVersion) -> CampaignConfig:
snapshot_path = _write_campaign_snapshot(version)
return load_campaign_config(snapshot_path)
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
if config.recipients.from_:
return config.recipients.from_[0].email, config.recipients.from_[0].name
if config.server.smtp and config.server.smtp.username and "@" in config.server.smtp.username:
return config.server.smtp.username, None
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
def _text_summary(report: dict[str, Any]) -> str:
campaign = report["campaign"]
cards = report["cards"]
status = report["status_counts"]
delivery = report.get("delivery", {})
lines = [
f"Campaign report: {campaign['name']}",
"",
f"Campaign ID: {campaign['id']}",
f"External ID: {campaign['external_id']}",
f"Status: {campaign['status']}",
"",
"Overview",
f"- Jobs total: {cards['jobs_total']}",
f"- Queueable: {cards['queueable']}",
f"- Needs attention: {cards['needs_attention']}",
f"- Sent: {cards['sent']}",
f"- Failed: {cards['failed']}",
f"- IMAP appended: {cards['imap_appended']}",
f"- IMAP failed: {cards['imap_failed']}",
"",
f"Build status: {status.get('build', {})}",
f"Validation status: {status.get('validation', {})}",
f"Queue status: {status.get('queue', {})}",
f"Send status: {status.get('send', {})}",
f"IMAP status: {status.get('imap', {})}",
]
if delivery.get("estimated_remaining_send_human"):
lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"])
lines.extend(["", "This report was generated by MultiMailer."])
return "\n".join(lines)
def build_report_message(
*,
campaign: Campaign,
config: CampaignConfig,
report: dict[str, Any],
to: list[str],
jobs_csv: str | None = None,
report_json: dict[str, Any] | None = None,
) -> EmailMessage:
from_email, from_name = _effective_from(config)
subject = f"MultiMailer report: {campaign.name}"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((from_name or from_email, from_email))
msg["To"] = ", ".join(to)
msg["X-MultiMailer-Report"] = "campaign"
msg.set_content(_text_summary(report))
if jobs_csv is not None:
filename = f"multimailer-{campaign.external_id}-jobs.csv"
msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename)
if report_json is not None:
filename = f"multimailer-{campaign.external_id}-report.json"
msg.add_attachment(
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
maintype="application",
subtype="json",
filename=filename,
)
return msg
def send_campaign_report_email(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
to: list[str],
include_jobs: bool = False,
attach_jobs_csv: bool = True,
attach_report_json: bool = False,
dry_run: bool = False,
) -> CampaignReportEmailResult:
campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise CampaignReportError("Campaign not found")
if not to:
raise CampaignReportEmailError("At least one report recipient is required")
version = _selected_version(session, campaign, version_id)
config = _load_config(version)
smtp_config: SmtpConfig | None = config.server.smtp
if smtp_config is None:
raise SmtpConfigurationError("Campaign has no SMTP configuration")
report = generate_campaign_report(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version.id,
include_jobs=include_jobs,
)
jobs_csv = (
generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id)
if attach_jobs_csv
else None
)
report_json = report if attach_report_json else None
message = build_report_message(
campaign=campaign,
config=config,
report=report,
to=to,
jobs_csv=jobs_csv,
report_json=report_json,
)
envelope_from, _ = _effective_from(config)
if dry_run:
return CampaignReportEmailResult(
campaign_id=campaign.id,
version_id=version.id,
to=to,
subject=str(message["Subject"]),
dry_run=True,
sent=False,
attached_jobs_csv=jobs_csv is not None,
attached_report_json=report_json is not None,
smtp_host=smtp_config.host,
smtp_port=smtp_config.port,
)
result: SmtpSendResult = send_email_message(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=to,
)
return CampaignReportEmailResult(
campaign_id=campaign.id,
version_id=version.id,
to=to,
subject=str(message["Subject"]),
dry_run=False,
sent=True,
attached_jobs_csv=jobs_csv is not None,
attached_report_json=report_json is not None,
smtp_host=result.host,
smtp_port=result.port,
accepted_count=result.accepted_count,
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,555 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
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 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
class CampaignVersionSetStepRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
current_flow: str | None = None
current_step: str
class CampaignReviewStateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
inspection_complete: bool = False
reviewed_message_keys: list[str] = Field(default_factory=list)
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
schema_version: str
source_filename: str | None = None
source_base_path: 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
class CampaignVersionDetailResponse(CampaignVersionResponse):
raw_json: dict[str, Any]
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 CampaignCreateResponse(BaseModel):
campaign: CampaignResponse
version: CampaignVersionResponse
class CampaignListResponse(BaseModel):
campaigns: list[CampaignResponse]
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]
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
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
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 CampaignJobDetailResponse(BaseModel):
job: dict[str, Any]
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 CampaignResolveOutcomeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
decision: Literal["smtp_accepted", "not_sent"]
note: str | None = None
class ValidateCampaignRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
check_files: bool = False
class BuildCampaignRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
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."""
enabled: bool = True
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")
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
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 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
dry_run: bool = False
class CampaignActionResponse(BaseModel):
result: dict[str, Any]
class ReportEmailRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
to: list[str]
version_id: str | None = None
include_jobs: bool = False
attach_jobs_csv: bool = True
attach_report_json: bool = False
dry_run: bool = False
class ReportEmailResponse(BaseModel):
result: dict[str, Any]
class AuditLogItemResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
tenant_id: str | None = None
user_id: str | None = None
api_key_id: str | None = None
action: str
object_type: str | None = None
object_id: str | None = None
details: dict[str, Any] | None = None
created_at: datetime
class AuditLogListResponse(BaseModel):
items: list[AuditLogItemResponse]
class LoginRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
email: str
password: str
# Kept optional for backwards compatibility and future tenant-switch login flows.
# The WebUI no longer sends it. If omitted, the backend resolves the user by email.
tenant_slug: str | None = None
class SwitchTenantRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str
class TenantInfo(BaseModel):
id: str
slug: str
name: str
is_active: bool = True
default_locale: str = "en"
class TenantMembershipInfo(TenantInfo):
roles: list[str] = Field(default_factory=list)
is_active: bool = True
class UserInfo(BaseModel):
id: str
account_id: str
email: str
# Global account identity used by the title bar and account settings.
display_name: str | None = None
# Optional tenant-local alias used in tenant administration and ownership.
tenant_display_name: str | None = None
is_tenant_admin: bool = False
password_reset_required: bool = False
class ProfileUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
display_name: str | None = Field(default=None, max_length=255)
tenant_display_name: str | None = Field(default=None, max_length=255)
class RoleInfo(BaseModel):
id: str
slug: str
name: str
permissions: list[str] = Field(default_factory=list)
level: Literal["tenant", "system"] = "tenant"
class GroupInfo(BaseModel):
id: str
slug: str
name: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_at: datetime
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)
class MeResponse(BaseModel):
user: UserInfo
# Backwards-compatible alias for the active tenant.
tenant: TenantInfo
active_tenant: TenantInfo
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
scopes: list[str]
roles: list[RoleInfo] = Field(default_factory=list)
groups: list[GroupInfo] = Field(default_factory=list)

View File

@@ -0,0 +1,282 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any, Iterable
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
from govoplan_mail.backend.mail_profiles import MailProfileError, apply_campaign_credentials, effective_profile_credentials_inherited, ensure_mail_profile_allowed_for_campaign, imap_config_from_profile, mail_profile_id_from_campaign_json, smtp_config_from_profile
SNAPSHOT_VERSION = "3"
class ExecutionSnapshotError(RuntimeError):
pass
class ExecutionSnapshot(BaseModel):
"""Immutable delivery inputs for one built campaign version.
Rendered messages and attachment evidence remain normalized in
``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the
mutable transport/runtime configuration and cryptographically binds it to
the build and the exact set of persisted jobs without duplicating all
recipient data into one large JSON value.
"""
model_config = ConfigDict(extra="forbid")
snapshot_version: str = SNAPSHOT_VERSION
campaign_version_id: str
campaign_json_sha256: str
created_at: str
build_token: str | None = None
built_at: str | None = None
job_count: int = 0
queueable_job_count: int = 0
job_manifest_sha256: str | None = None
effective_policy_sha256: str | None = None
smtp_config_fingerprint: str | None = None
imap_config_fingerprint: str | None = None
smtp: SmtpConfig
imap: ImapConfig | None = None
delivery: DeliveryConfig
def _canonical_json(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
def _sha256(value: Any) -> str:
return hashlib.sha256(_canonical_json(value)).hexdigest()
def snapshot_hash(payload: dict[str, Any]) -> str:
return _sha256(payload)
def _transport_fingerprint(config: SmtpConfig | ImapConfig | None) -> str | None:
if config is None:
return None
payload = config.model_dump(mode="json")
# The fingerprint is safe to expose in reports. It identifies the effective
# account/transport settings without incorporating or revealing the secret.
if "password" in payload:
payload["password"] = "<configured>" if payload.get("password") else None
return _sha256(payload)
def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpConfig | ImapConfig | None:
if config is None:
return None
payload = config.model_dump(mode="json")
payload["password"] = None
if isinstance(config, SmtpConfig):
return SmtpConfig.model_validate(payload)
return ImapConfig.model_validate(payload)
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
config = server.get(name) if isinstance(server, dict) else None
password = config.get("password") if isinstance(config, dict) else None
if password is None:
return None
return str(password)
def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any]:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
return server if isinstance(server, dict) else {}
def _profile_for_version(session: Session, version: CampaignVersion):
profile_id = mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
if not profile_id:
return None
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
try:
return ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
except MailProfileError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> SmtpConfig:
payload = snapshot.smtp.model_dump(mode="json")
if not payload.get("password"):
server = _server_from_campaign_json(version.raw_json)
profile = _profile_for_version(session, version)
if profile is not None:
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
profile_payload = smtp_config_from_profile(profile).model_dump(mode="json")
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
return SmtpConfig.model_validate(profile_payload)
return SmtpConfig.model_validate(apply_campaign_credentials(profile_payload, server, "smtp"))
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
return SmtpConfig.model_validate(payload)
def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> ImapConfig | None:
server = _server_from_campaign_json(version.raw_json)
if snapshot.imap is None:
profile = _profile_for_version(session, version)
if profile is None:
return None
imap = imap_config_from_profile(profile)
if imap is None:
return None
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
imap_payload = imap.model_dump(mode="json")
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
return ImapConfig.model_validate(imap_payload)
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
payload = snapshot.imap.model_dump(mode="json")
if not payload.get("password"):
profile = _profile_for_version(session, version)
if profile is not None:
imap = imap_config_from_profile(profile)
if imap is not None:
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
imap_payload = imap.model_dump(mode="json")
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
return ImapConfig.model_validate(imap_payload)
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
return ImapConfig.model_validate(payload)
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
return _sha256(
{
"validation_policy": raw_json.get("validation_policy"),
"policy": raw_json.get("policy"),
"delivery": delivery.model_dump(mode="json"),
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
if isinstance(raw_json.get("attachments"), dict)
else None,
}
)
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
"""Hash the immutable per-message execution records in stable order."""
payload: list[dict[str, Any]] = []
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)):
payload.append(
{
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"subject": job.subject,
"message_id_header": job.message_id_header,
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"build_status": job.build_status,
"validation_status": job.validation_status,
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []),
}
)
return _sha256(payload)
def create_execution_snapshot(
version: CampaignVersion,
*,
smtp: SmtpConfig,
imap: ImapConfig | None,
delivery: DeliveryConfig,
jobs: Iterable[CampaignJob] = (),
build_summary: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs)
summary = build_summary if isinstance(build_summary, dict) else {}
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
redacted_smtp = _redacted_transport_config(smtp)
redacted_imap = _redacted_transport_config(imap)
assert isinstance(redacted_smtp, SmtpConfig)
assert redacted_imap is None or isinstance(redacted_imap, ImapConfig)
payload = ExecutionSnapshot(
campaign_version_id=version.id,
campaign_json_sha256=_sha256(raw_json),
build_token=str(summary.get("build_token") or "") or None,
built_at=str(summary.get("built_at") or "") or None,
job_count=len(job_list),
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
smtp_config_fingerprint=_transport_fingerprint(smtp),
imap_config_fingerprint=_transport_fingerprint(imap),
created_at=datetime.now(timezone.utc).isoformat(),
smtp=redacted_smtp,
imap=redacted_imap,
delivery=delivery,
).model_dump(mode="json")
return payload, snapshot_hash(payload)
def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> ExecutionSnapshot:
"""Return a validated snapshot, creating one for pre-migration builds.
New builds create the snapshot after persisting their jobs. The fallback is
intentionally limited to legacy built versions so they can be operated
without a manual data migration.
"""
if isinstance(version.execution_snapshot, dict):
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json"))
if version.execution_snapshot_hash and version.execution_snapshot_hash != expected:
raise ExecutionSnapshotError("Execution snapshot checksum mismatch")
return snapshot
from govoplan_campaign.backend.persistence.campaigns import load_version_config
_, _, config = load_version_config(session, version.id)
if not config.server.smtp:
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
.all()
)
if not jobs:
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
payload, digest = create_execution_snapshot(
version,
smtp=config.server.smtp,
imap=config.server.imap,
delivery=config.delivery,
jobs=jobs,
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
)
version.execution_snapshot = payload
version.execution_snapshot_hash = digest
version.execution_snapshot_at = datetime.now(timezone.utc)
session.add(version)
session.flush()
return ExecutionSnapshot.model_validate(payload)
def clear_execution_snapshot(version: CampaignVersion) -> None:
version.execution_snapshot = None
version.execution_snapshot_hash = None
version.execution_snapshot_at = None

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
from __future__ import annotations
from pathlib import Path
from govoplan_campaign.backend.domain.campaign import MailAttachmentConfig
def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]:
directory = base_path / config.base_dir
if not directory.exists():
return []
iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter)
return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0)

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from pathlib import Path
from typing import Iterable
from govoplan_campaign.backend.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity
from govoplan_campaign.backend.domain.queue import MailQueue
from govoplan_campaign.backend.domain.recipients import Recipient, RecipientType
from govoplan_campaign.backend.services.attachment_matching import match_files
from govoplan_campaign.backend.services.zip_service import create_encrypted_zip
def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str:
return ", ".join(r.formatted() for r in recipients if r.type == recipient_type)
def _recipient_values(recipients: list[Recipient]) -> dict[str, str]:
def rows(recipient_type: RecipientType) -> list[Recipient]:
return [r for r in recipients if r.type == recipient_type]
def joined(recipient_type: RecipientType, mode: str) -> str:
selected = rows(recipient_type)
if mode == "address":
return ", ".join(r.address for r in selected)
if mode == "name":
return ", ".join(r.name or r.address for r in selected)
return ", ".join(r.formatted() for r in selected)
return {
"mm_recipients": joined(RecipientType.TO, "formatted"),
"mm_recipients_address": joined(RecipientType.TO, "address"),
"mm_recipients_name": joined(RecipientType.TO, "name"),
"mm_cc": joined(RecipientType.CC, "formatted"),
"mm_cc_address": joined(RecipientType.CC, "address"),
"mm_cc_name": joined(RecipientType.CC, "name"),
"mm_bcc": joined(RecipientType.BCC, "formatted"),
"mm_bcc_address": joined(RecipientType.BCC, "address"),
"mm_bcc_name": joined(RecipientType.BCC, "name"),
}
def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]:
paths: list[Path] = []
if entry.combine_attachments:
for config in campaign.global_attachment_configs:
paths.extend(match_files(campaign.base_attachment_path, config))
if campaign.individual_attachments:
for config in entry.attachment_configs:
paths.extend(match_files(campaign.base_attachment_path, config))
return paths
def get_not_sent_files(campaign: MailCampaign) -> set[Path]:
"""Return files matching campaign attachment configs that are not referenced by any built entry."""
all_files: set[Path] = set()
used_files: set[Path] = set()
for config in campaign.global_attachment_configs:
all_files.update(match_files(campaign.base_attachment_path, config))
for entry in campaign.mail_entries:
files = set(_message_attachment_paths(campaign, entry))
used_files.update(files)
all_files.update(files)
return all_files - used_files
def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None:
recipients = campaign.all_recipients_for(entry)
attachments = _message_attachment_paths(campaign, entry)
if not attachments and not campaign.send_without_attachments:
return None
values = {}
values.update(_recipient_values(recipients))
values.update(campaign.field_contents.as_value_map("global"))
values.update(entry.field_contents.as_value_map("local"))
message = EmailMessage()
sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from
if sender:
message["From"] = sender.formatted()
to_header = _recipient_header(recipients, RecipientType.TO)
cc_header = _recipient_header(recipients, RecipientType.CC)
if to_header:
message["To"] = to_header
if cc_header:
message["Cc"] = cc_header
message["Subject"] = campaign.subject_template.apply_values(values)
message.set_content(campaign.mail_template.apply_values(values))
attachment_paths = attachments
if attachments and zip_attachments:
number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments"
password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else ""
zip_path = campaign.base_attachment_path / f"{number}.zip"
attachment_paths = [create_encrypted_zip(zip_path, attachments, password)]
for attachment_path in attachment_paths:
data = attachment_path.read_bytes()
message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name)
return message
def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue:
queue = MailQueue()
for entry in campaign.mail_entries:
if not entry.is_active:
continue
message = build_message(campaign, entry, zip_attachments=zip_attachments)
if message is not None:
queue.add_mail(message)
return queue
def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue:
retry_queue = MailQueue()
if settings.transport_security == TransportSecurity.TLS:
smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60)
else:
smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60)
try:
if settings.transport_security == TransportSecurity.STARTTLS:
smtp.starttls()
if settings.username:
smtp.login(settings.username, settings.password)
for message in queue:
try:
smtp.send_message(message)
except Exception:
retry_queue.add_mail(message)
finally:
smtp.quit()
return retry_queue

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import zipfile
from pathlib import Path
from typing import Iterable
try:
import pyzipper
except ImportError: # pragma: no cover
pyzipper = None
ArchiveMember = tuple[Path, str]
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
members: list[ArchiveMember] = []
used_names: set[str] = set()
for item in files:
path, requested_name = item if isinstance(item, tuple) else (item, item.name)
requested = Path(requested_name).name or path.name
stem = Path(requested).stem
suffix = Path(requested).suffix
candidate = requested
counter = 2
while candidate.casefold() in used_names:
candidate = f"{stem} ({counter}){suffix}"
counter += 1
used_names.add(candidate.casefold())
members.append((path, candidate))
return members
def create_zip_archive(
output_path: Path,
files: Iterable[Path | ArchiveMember],
password: str = "",
) -> Path:
"""Create a ZIP archive, using AES encryption when a password is supplied."""
output_path.parent.mkdir(parents=True, exist_ok=True)
members = _normalized_members(files)
if password:
if pyzipper is None:
raise RuntimeError("pyzipper is required for writing password-protected ZIP files")
with pyzipper.AESZipFile(
output_path,
"w",
compression=pyzipper.ZIP_DEFLATED,
encryption=pyzipper.WZ_AES,
) as zip_file:
zip_file.setpassword(password.encode("utf-8"))
for file_path, archive_name in members:
zip_file.write(file_path, arcname=archive_name)
return output_path
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
for file_path, archive_name in members:
zip_file.write(file_path, arcname=archive_name)
return output_path
def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path:
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
return create_zip_archive(output_path, files, password)

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime, timezone
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def ensure_aware_utc(value: datetime | None) -> datetime | None:
"""Return a timezone-aware UTC datetime.
SQLite and some DB drivers may return naive datetimes even when SQLAlchemy
columns are declared as DateTime(timezone=True). Treat naive values as UTC
so comparisons with aware `datetime.now(timezone.utc)` do not crash.
"""
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)

View File

27
webui/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
},
"dependencies": {
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.0",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
}
}

519
webui/src/api/admin.ts Normal file
View File

@@ -0,0 +1,519 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = {
account_id: string;
email: string;
display_name?: string | null;
};
export type RoleSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_builtin: boolean;
is_assignable: boolean;
user_assignments: number;
group_assignments: number;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type GroupSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
member_count: number;
member_ids: string[];
roles: RoleSummary[];
created_at: string;
updated_at: string;
system_template_id?: string | null;
system_required?: boolean;
};
export type UserAdminItem = {
id: string;
account_id: string;
tenant_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
account_is_active: boolean;
password_reset_required: boolean;
last_login_at?: string | null;
groups: GroupSummary[];
roles: RoleSummary[];
effective_scopes: string[];
is_owner: boolean;
is_last_active_owner: boolean;
created_at: string;
updated_at: string;
};
export type SystemAccountItem = {
account_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
roles: RoleSummary[];
last_login_at?: string | null;
};
export type SystemMembershipDraft = {
tenant_id: string;
is_active: boolean;
role_ids: string[];
group_ids: string[];
is_owner?: boolean;
is_last_active_owner?: boolean;
};
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
};
export type SystemSettingsItem = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
settings: Record<string, unknown>;
};
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
export type GovernanceAssignment = {
tenant_id: string;
mode: "available" | "required";
};
export type GovernanceTemplateItem = {
id: string;
kind: "group" | "role";
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_active: boolean;
assignments: GovernanceAssignment[];
created_at: string;
updated_at: string;
};
export type ApiKeyAdminItem = {
id: string;
user_id: string;
user_email: string;
name: string;
prefix: string;
scopes: string[];
expires_at?: string | null;
last_used_at?: string | null;
revoked_at?: string | null;
created_at: string;
};
export type AuditAdminItem = {
id: string;
scope: "tenant" | "system";
tenant_id?: string | null;
actor_email?: string | null;
action: string;
object_type?: string | null;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
return response.accounts;
}
export function createTenant(settings: ApiSettings, payload: {
slug: string;
name: string;
owner_account_id?: string | null;
description?: string | null;
default_locale?: string;
settings?: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
}): Promise<TenantAdminItem> {
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
}
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
name: string;
description: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
}>): Promise<TenantAdminItem> {
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
return response.users;
}
export function createUser(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
group_ids?: string[];
role_ids?: string[];
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
}
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
display_name: string | null;
is_active: boolean;
group_ids: string[];
role_ids: string[];
}>): Promise<UserAdminItem> {
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
return response.groups;
}
export function createGroup(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
is_active?: boolean;
member_ids?: string[];
role_ids?: string[];
}): Promise<GroupSummary> {
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
name: string;
description: string | null;
is_active: boolean;
member_ids: string[];
role_ids: string[];
}>): Promise<GroupSummary> {
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
return response.roles;
}
export function createRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
return response.roles;
}
export function createSystemRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
return apiFetch(settings, "/api/v1/admin/system/accounts");
}
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null;
is_active?: boolean;
role_ids?: string[];
}): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
method: "PUT",
body: JSON.stringify({ role_ids: roleIds })
});
}
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams();
if (includeRevoked) params.set("include_revoked", "true");
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
return response.api_keys;
}
export function createApiKey(settings: ApiSettings, payload: {
name: string;
user_id?: string | null;
scopes: string[];
expires_at?: string | null;
}): Promise<ApiKeyAdminItem & { secret: string }> {
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
}
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
}
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
role_ids?: string[];
memberships?: SystemMembershipDraft[];
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
method: "PUT", body: JSON.stringify({ memberships })
});
}
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings");
}
export type SystemSettingsUpdatePayload = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
};
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
}
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) });
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count">): Promise<GovernanceTemplateItem> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" });
}

680
webui/src/api/campaigns.ts Normal file
View File

@@ -0,0 +1,680 @@
import type { ApiSettings, CampaignListItem } from "../types";
import { apiDownload, apiFetch } from "./client";
export type CampaignListResponse =
| CampaignListItem[]
| {
campaigns?: CampaignListItem[];
items?: CampaignListItem[];
results?: CampaignListItem[];
};
export type CampaignShare = {
id: string;
campaign_id: string;
target_type: "user" | "group";
target_id: string;
permission: "read" | "write";
revoked_at?: string | null;
};
export type CampaignShareTarget = { id: string; name: string; secondary?: string | null };
export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] };
export type CampaignUpdatePayload = {
external_id?: string | null;
name?: string | null;
status?: string | null;
description?: string | null;
};
export type CampaignCreateMinimalPayload = {
external_id?: string;
name?: string;
description?: string | null;
current_flow?: string;
current_step?: string;
};
export type CampaignCreateResponse = {
campaign: CampaignListItem & {
current_version_id?: string | null;
};
version: CampaignVersionListItem;
};
export type CampaignVersionListItem = {
id: string;
campaign_id: string;
version_number: number;
schema_version?: string;
source_filename?: string | null;
source_base_path?: string | null;
workflow_state?: string;
current_flow?: string;
current_step?: string | null;
is_complete?: boolean;
editor_state?: Record<string, unknown>;
autosaved_at?: string | null;
published_at?: string | null;
locked_at?: string | null;
locked_by_user_id?: string | null;
user_lock_state?: "temporary" | "permanent" | null;
user_locked_at?: string | null;
user_locked_by_user_id?: string | null;
created_at?: string;
updated_at?: string;
validation_summary?: Record<string, unknown> | null;
build_summary?: Record<string, unknown> | null;
execution_snapshot_hash?: string | null;
execution_snapshot_at?: string | null;
};
export type CampaignVersionDetail = CampaignVersionListItem & {
raw_json: Record<string, unknown>;
campaign_json?: Record<string, unknown>;
};
export type CampaignVersionUpdatePayload = {
campaign_json?: Record<string, unknown> | null;
current_flow?: string | null;
current_step?: string | null;
workflow_state?: string | null;
is_complete?: boolean | null;
editor_state?: Record<string, unknown> | null;
source_filename?: string | null;
source_base_path?: string | null;
};
export type CampaignPartialValidationPayload = {
campaign_json?: Record<string, unknown> | null;
section?: string | null;
};
export type CampaignPartialValidationResponse = {
ok: boolean;
section?: string | null;
error_count: number;
warning_count: number;
info_count: number;
issues: Record<string, unknown>[];
};
export type CampaignSummary = {
generated_at?: string;
selected_version_id?: string | null;
campaign?: CampaignListItem;
current_version?: {
id: string;
version_number?: number;
schema_version?: string;
source_filename?: string | null;
created_at?: string | null;
validation_summary?: Record<string, unknown> | null;
build_summary?: Record<string, unknown> | null;
} | null;
cards?: {
jobs_total?: number;
inactive?: number;
queueable?: number;
needs_attention?: number;
sent?: number;
smtp_accepted?: number;
failed?: number;
outcome_unknown?: number;
not_attempted?: number;
queued_or_active?: number;
cancelled?: number;
partially_completed?: boolean;
imap_appended?: number;
imap_failed?: number;
};
status_counts?: Record<string, Record<string, number>>;
issues?: Record<string, unknown>;
attachments?: Record<string, unknown>;
attempts?: Record<string, unknown>;
delivery?: Record<string, unknown>;
recent_failures?: Record<string, unknown>[];
};
export type CampaignQueuePayload = {
version_id?: string | null;
include_warnings?: boolean;
enqueue_celery?: boolean;
dry_run?: boolean;
};
export type CampaignSendNowPayload = {
version_id?: string | null;
include_warnings?: boolean;
check_files?: boolean;
validate_before_send?: boolean;
build_before_send?: boolean;
dry_run?: boolean;
use_rate_limit?: boolean;
enqueue_imap_task?: boolean;
};
export type CampaignAttachmentPreviewFile = {
id: string;
version_id?: string;
blob_id?: string;
display_path: string;
filename: string;
owner_type: string;
owner_id: string;
checksum_sha256?: string;
size_bytes?: number;
content_type?: string | null;
};
export type CampaignAttachmentPreviewRule = {
source: "global" | "entry";
entry_index: number;
entry_id?: string | null;
index: number;
attachment_id?: string | null;
label?: string | null;
required: boolean;
pattern: string;
base_path_name?: string | null;
base_path?: string | null;
status: "ok" | "missing" | "ambiguous";
behavior?: string | null;
zip_included?: boolean;
zip_mode?: "inherit" | "include" | "exclude";
zip_archive_id?: string | null;
zip_filename?: string | null;
matches: CampaignAttachmentPreviewFile[];
match_count: number;
issues: Record<string, unknown>[];
};
export type CampaignAttachmentPreviewResponse = {
campaign_id: string;
version_id: string;
shared_file_count: number;
rules: CampaignAttachmentPreviewRule[];
unused_shared_files: CampaignAttachmentPreviewFile[];
};
export type CampaignAttachmentPreviewPayload = {
include_unmatched?: boolean;
campaign_json?: Record<string, unknown>;
};
export type CampaignMockSendPayload = {
version_id?: string | null;
send?: boolean;
include_warnings?: boolean;
include_needs_review?: boolean;
append_sent?: boolean;
clear_mailbox?: boolean;
check_files?: boolean;
};
export type CampaignReviewStatePayload = {
inspection_complete: boolean;
reviewed_message_keys: string[];
};
export type CampaignJobsQuery = {
versionId?: string;
page?: number;
pageSize?: number;
sendStatus?: string[];
validationStatus?: string[];
imapStatus?: string[];
query?: string;
};
export type CampaignJobsResponse = {
jobs: Record<string, unknown>[];
page: number;
page_size: number;
total: number;
total_unfiltered: number;
pages: number;
counts: Record<string, Record<string, number>>;
filtered_counts: Record<string, Record<string, number>>;
review: {
inspection_complete?: boolean;
blocking_count?: number;
required_count?: number;
reviewed_required_count?: number;
bulk_acceptable_count?: number;
};
};
export type CampaignJobDetailResponse = {
job: Record<string, unknown>;
attempts: {
smtp?: Record<string, unknown>[];
imap?: Record<string, unknown>[];
};
};
export type CampaignReportEmailPayload = {
to: string[];
version_id?: string;
include_jobs?: boolean;
attach_jobs_csv?: boolean;
attach_report_json?: boolean;
dry_run?: boolean;
};
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
if (Array.isArray(response)) {
return response;
}
return response.campaigns ?? response.items ?? response.results ?? [];
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}
export async function updateCampaignMetadata(
settings: ApiSettings,
campaignId: string,
payload: CampaignUpdatePayload
): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function createNewCampaign(
settings: ApiSettings,
overrides: CampaignCreateMinimalPayload = {}
): Promise<CampaignCreateResponse> {
const now = new Date();
const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, "");
const payload = {
external_id: overrides.external_id ?? `new-campaign-${stamp}`,
name: overrides.name ?? "New Campaign",
description: overrides.description ?? "",
current_flow: overrides.current_flow ?? "create",
current_step: overrides.current_step ?? "basics"
};
return apiFetch<CampaignCreateResponse>(settings, "/api/v1/campaigns/new", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function getCampaignSchema(settings: ApiSettings): Promise<unknown> {
return apiFetch(settings, "/api/v1/schemas/campaign");
}
export async function listCampaignVersions(
settings: ApiSettings,
campaignId: string
): Promise<CampaignVersionListItem[]> {
return apiFetch<CampaignVersionListItem[]>(settings, `/api/v1/campaigns/${campaignId}/versions`);
}
export async function getCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
}
export async function unlockCampaignVersionValidation(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, {
method: "POST"
});
}
export async function lockCampaignVersionTemporarily(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, {
method: "POST"
});
}
export async function unlockCampaignVersionUserLock(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, {
method: "POST"
});
}
export async function lockCampaignVersionPermanently(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, {
method: "POST"
});
}
export async function updateCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export async function forkCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload = {}
): Promise<CampaignCreateResponse> {
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function autosaveCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignVersionUpdatePayload
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function setCampaignVersionStep(
settings: ApiSettings,
campaignId: string,
versionId: string,
currentStep: string,
currentFlow?: string | null
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, {
method: "POST",
body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep })
});
}
export async function validatePartial(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignPartialValidationPayload = {}
): Promise<CampaignPartialValidationResponse> {
return apiFetch<CampaignPartialValidationResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function publishCampaignVersion(
settings: ApiSettings,
campaignId: string,
versionId: string
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, {
method: "POST"
});
}
export async function validateVersion(
settings: ApiSettings,
versionId: string,
checkFiles = false
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
method: "POST",
body: JSON.stringify({ check_files: checkFiles })
});
}
export async function buildVersion(
settings: ApiSettings,
versionId: string,
writeEml = true
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
method: "POST",
body: JSON.stringify({ write_eml: writeEml })
});
}
export function previewCampaignAttachments(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignAttachmentPreviewPayload = {}
): Promise<CampaignAttachmentPreviewResponse> {
return apiFetch<CampaignAttachmentPreviewResponse>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function getCampaignSummary(
settings: ApiSettings,
campaignId: string,
versionId?: string
): Promise<CampaignSummary> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
}
export async function getCampaignJobs(
settings: ApiSettings,
campaignId: string,
options: CampaignJobsQuery = {}
): Promise<CampaignJobsResponse> {
const params = new URLSearchParams();
if (options.versionId) params.set("version_id", options.versionId);
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
for (const value of options.sendStatus ?? []) params.append("send_status", value);
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
if (options.query?.trim()) params.set("q", options.query.trim());
const suffix = params.size > 0 ? `?${params.toString()}` : "";
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
}
export async function getCampaignJobDetail(
settings: ApiSettings,
campaignId: string,
jobId: string
): Promise<CampaignJobDetailResponse> {
return apiFetch<CampaignJobDetailResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`);
}
export async function getCampaignReport(
settings: ApiSettings,
campaignId: string,
versionId?: string
): Promise<CampaignSummary> {
const params = new URLSearchParams({ include_jobs: "false" });
if (versionId) params.set("version_id", versionId);
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
}
export async function downloadCampaignJobsCsv(
settings: ApiSettings,
campaignId: string,
versionId?: string
): Promise<void> {
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
await apiDownload(
settings,
`/api/v1/campaigns/${campaignId}/report/jobs.csv${suffix}`,
`campaign-${campaignId}${versionId ? `-${versionId}` : ""}-jobs.csv`
);
}
export async function emailCampaignReport(
settings: ApiSettings,
campaignId: string,
payload: CampaignReportEmailPayload
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/report/email`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function retryCampaignJobs(
settings: ApiSettings,
campaignId: string,
payload: Record<string, unknown> = {}
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function sendUnattemptedCampaignJobs(
settings: ApiSettings,
campaignId: string,
payload: Record<string, unknown> = {}
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function resolveCampaignJobOutcome(
settings: ApiSettings,
campaignId: string,
jobId: string,
decision: "smtp_accepted" | "not_sent",
note?: string
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
method: "POST",
body: JSON.stringify({ decision, note: note || null })
});
}
export async function updateCampaignReviewState(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignReviewStatePayload
): Promise<CampaignVersionDetail> {
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function queueCampaign(
settings: ApiSettings,
campaignId: string,
payload: CampaignQueuePayload = {}
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/queue`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function sendCampaignNow(
settings: ApiSettings,
campaignId: string,
payload: CampaignSendNowPayload = {}
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/send-now`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function mockSendCampaign(
settings: ApiSettings,
campaignId: string,
payload: CampaignMockSendPayload = {}
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function pauseCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/pause`, { method: "POST" });
}
export async function resumeCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/resume`, { method: "POST" });
}
export async function cancelCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/cancel`, { method: "POST" });
}
export async function appendSent(
settings: ApiSettings,
campaignId: string,
dryRun = false
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
method: "POST",
body: JSON.stringify({ dry_run: dryRun })
});
}
export async function getCampaignShareTargets(settings: ApiSettings, campaignId: string): Promise<CampaignShareTargets> {
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
}
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
const response = await apiFetch<{ shares: CampaignShare[] }>(settings, `/api/v1/campaigns/${campaignId}/shares`);
return response.shares;
}
export async function updateCampaignOwner(
settings: ApiSettings,
campaignId: string,
payload: { owner_user_id?: string | null; owner_group_id?: string | null }
): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) });
}
export async function upsertCampaignShare(
settings: ApiSettings,
campaignId: string,
payload: { target_type: "user" | "group"; target_id: string; permission: "read" | "write" }
): Promise<CampaignShare> {
return apiFetch<CampaignShare>(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) });
}
export async function revokeCampaignShare(settings: ApiSettings, campaignId: string, shareId: string): Promise<void> {
await apiFetch<void>(settings, `/api/v1/campaigns/${campaignId}/shares/${shareId}`, { method: "DELETE" });
}

1
webui/src/api/client.ts Normal file
View File

@@ -0,0 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";

1
webui/src/api/files.ts Normal file
View File

@@ -0,0 +1 @@
export * from "@govoplan/files-webui";

1
webui/src/api/mail.ts Normal file
View File

@@ -0,0 +1 @@
export * from "@govoplan/mail-webui";

View File

@@ -0,0 +1,2 @@
import { Button } from "@govoplan/core-webui";
export default Button;

View File

@@ -0,0 +1,2 @@
import { Card } from "@govoplan/core-webui";
export default Card;

View File

@@ -0,0 +1,2 @@
import { ConfirmDialog } from "@govoplan/core-webui";
export default ConfirmDialog;

View File

@@ -0,0 +1,2 @@
import { Dialog } from "@govoplan/core-webui";
export default Dialog;

View File

@@ -0,0 +1,2 @@
import { DismissibleAlert } from "@govoplan/core-webui";
export default DismissibleAlert;

View File

@@ -0,0 +1,2 @@
import { FormField } from "@govoplan/core-webui";
export default FormField;

View File

@@ -0,0 +1,2 @@
import { LoadingFrame } from "@govoplan/core-webui";
export default LoadingFrame;

View File

@@ -0,0 +1,2 @@
import { LoadingIndicator } from "@govoplan/core-webui";
export default LoadingIndicator;

View File

@@ -0,0 +1,2 @@
import { MetricCard } from "@govoplan/core-webui";
export default MetricCard;

View File

@@ -0,0 +1,2 @@
import { PageTitle } from "@govoplan/core-webui";
export default PageTitle;

View File

@@ -0,0 +1,2 @@
import { StatusBadge } from "@govoplan/core-webui";
export default StatusBadge;

View File

@@ -0,0 +1,2 @@
import { Stepper } from "@govoplan/core-webui";
export default Stepper;

View File

@@ -0,0 +1,2 @@
import { ToggleSwitch } from "@govoplan/core-webui";
export default ToggleSwitch;

View File

@@ -0,0 +1,2 @@
import { EmailAddressInput } from "@govoplan/core-webui";
export default EmailAddressInput;

View File

@@ -0,0 +1,2 @@
import { FieldLabel } from "@govoplan/core-webui";
export default FieldLabel;

View File

@@ -0,0 +1,2 @@
import { InlineHelp } from "@govoplan/core-webui";
export default InlineHelp;

View File

@@ -0,0 +1,4 @@
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
export { DataGridEmptyAction, DataGridRowActions };
export default DataGrid;

View File

@@ -0,0 +1,97 @@
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
const personalContacts = [
{ name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" },
{ name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" }
];
const groupContacts = [
{ name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" },
{ name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" }
];
const tenantContacts = [
{ name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" },
{ name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" }
];
function contactColumns(): DataGridColumn<{ name: string; email: string; source: string; tags: string }>[] {
return [
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
{ id: "email", header: "Email", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
{ id: "scope", header: "Scope", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "Personal" }, { value: "Group", label: "Group" }, { value: "Tenant", label: "Tenant" }] }, value: (contact) => contact.source },
{ id: "tags", header: "Tags", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "Mock" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }
];
}
export default function AddressBookPage() {
const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts];
return (
<div className="content-pad workspace-data-page module-entry-page address-book-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle>Address Book</PageTitle>
<p>Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.</p>
</div>
<div className="button-row compact-actions">
<Button disabled>Import</Button>
<Button variant="primary" disabled>Add contact</Button>
</div>
</div>
<div className="metric-grid">
<Card title="Personal"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">Private contacts and remembered addresses.</p></Card>
<Card title="Group"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">Shared group address books and lists.</p></Card>
<Card title="Tenant"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">Tenant directory and approved shared contacts.</p></Card>
<Card title="Sync"><strong className="module-big-number">Mock</strong><p className="muted">CardDAV/LDAP/import connectors can be added later.</p></Card>
</div>
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Address book scopes">
<div className="address-book-scope-list">
<AddressBookScope title="Personal address book" description="Private contacts, remembered recipients and personal aliases." status="Local" />
<AddressBookScope title="Group address books" description="Shared contact sets for teams, departments or campaign groups." status="Shared" />
<AddressBookScope title="Tenant directory" description="Tenant-wide contacts, functional mailboxes and approved lists." status="Directory" />
</div>
</Card>
<Card title="Planned address actions">
<div className="placeholder-stack">
<span>Choose addresses from personal, group or tenant source</span>
<span>Remember addresses used in campaigns after opt-in</span>
<span>Share selected contacts with a group</span>
<span>Use contacts in To, CC, BCC, sender and Reply-To fields</span>
</div>
</Card>
</div>
<Card title="Contacts" actions={<Button disabled>Manage sources</Button>}>
<DataGrid
id="address-book-contacts"
rows={allContacts}
columns={contactColumns()}
getRowKey={(contact) => contact.source + "-" + contact.email}
emptyText="No contacts found."
className="compact-table-wrap module-table"
/>
</Card>
</div>
);
}
function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) {
return (
<div className="address-book-scope-card">
<div>
<strong>{title}</strong>
<p>{description}</p>
</div>
<StatusBadge status={status} />
</div>
);
}

View File

@@ -0,0 +1,623 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil } from "lucide-react";
import type { ApiSettings } from "../../types";
import { listFileSpaces, type FileSpace } from "../../api/files";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import DismissibleAlert from "../../components/DismissibleAlert";
import ConfirmDialog from "../../components/ConfirmDialog";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { updateNested } from "./utils/draftEditor";
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
import ManagedFileChooser from "./components/ManagedFileChooser";
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
type PathChooserState = { index: number };
type IndividualDisableState = { index: number; usageCount: number };
export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [pathChooser, setPathChooser] = useState<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "files",
unsavedTitle: "Unsaved attachment settings",
unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const attachments = asRecord(displayDraft.attachments);
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
const zipArchiveNameValidation = useMemo(
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
[zipConfig.archives, zipConfig.enabled]
);
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
useEffect(() => {
let cancelled = false;
void listFileSpaces(settings)
.then((response) => { if (!cancelled) setFileSpaces(response.spaces); })
.catch(() => { if (!cancelled) setFileSpaces([]); });
return () => { cancelled = true; };
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
function patchBasePaths(paths: AttachmentBasePath[]) {
if (locked) return;
const normalized = ensureAttachmentBasePaths(paths);
setDraft((current) => {
const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized);
return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || ".");
});
markDirty();
}
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
}
function addBasePath(afterIndex = basePaths.length - 1) {
patchBasePaths(insertAfter(basePaths, afterIndex, createAttachmentBasePath("New attachment source", ".")));
}
function removeBasePath(index: number) {
patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index));
}
function moveBasePath(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
patchBasePaths(moveArrayItem(basePaths, index, targetIndex));
}
function setIndividualEligibility(index: number, checked: boolean) {
if (locked) return;
if (checked) {
patchBasePath(index, { allow_individual: true });
return;
}
const basePath = basePaths[index];
if (!basePath) return;
const usageCount = countIndividualAttachmentRulesForBasePath(displayDraft.entries, basePath);
if (usageCount > 0) {
setIndividualDisable({ index, usageCount });
return;
}
patchBasePath(index, { allow_individual: false });
}
function confirmIndividualDisable() {
if (!individualDisable) return;
const basePath = basePaths[individualDisable.index];
if (!basePath) {
setIndividualDisable(null);
return;
}
const nextPaths = basePaths.map((item, index) => index === individualDisable.index ? { ...item, allow_individual: false } : item);
setDraft((current) => {
const source = current ?? {};
const withPaths = updateNested(source, ["attachments", "base_paths"], nextPaths);
const withPrimaryPath = updateNested(withPaths, ["attachments", "base_path"], nextPaths[0]?.path || ".");
return updateNested(withPrimaryPath, ["entries"], removeIndividualAttachmentRulesForBasePath(asRecord(source).entries, basePath));
});
markDirty();
setIndividualDisable(null);
}
function patchZipCollection(next: AttachmentZipCollection) {
if (locked) return;
patch(["attachments", "zip"], next);
}
function setZipEnabled(enabled: boolean) {
const archives = enabled && zipConfig.archives.length === 0
? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)]
: zipConfig.archives;
patchZipCollection({ enabled, archives });
}
function patchZipArchive(index: number, archivePatch: Partial<AttachmentZipArchive>) {
const archives = zipConfig.archives.map((archive, currentIndex) => {
if (currentIndex !== index) return archive;
const next = { ...archive, ...archivePatch };
if (archivePatch.password_field !== undefined || archivePatch.password_scope !== undefined) {
next.password_mode = "field";
delete next.password;
delete next.password_template;
}
return next;
});
patchZipCollection({ ...zipConfig, archives });
}
function setStandardZipArchive(index: number) {
patchZipCollection({
...zipConfig,
archives: zipConfig.archives.map((archive, currentIndex) => ({ ...archive, standard: currentIndex === index }))
});
}
function addZipArchive(afterIndex = zipConfig.archives.length - 1) {
const archive = createAttachmentZipArchive(`attachments-${zipConfig.archives.length + 1}.zip`, zipConfig.archives.length === 0);
patchZipCollection({ ...zipConfig, archives: insertAfter(zipConfig.archives, afterIndex, archive) });
}
function removeZipArchive(index: number) {
const removed = zipConfig.archives[index];
if (!removed) return;
const removedWasStandard = removed.standard;
const archives = zipConfig.archives.filter((_, currentIndex) => currentIndex !== index);
if (removedWasStandard && archives.length > 0) archives[0] = { ...archives[0], standard: true };
const resetRule = (value: unknown) => {
const rule = asRecord(value);
const ruleZip = asRecord(rule.zip);
return String(ruleZip.archive_id ?? "") === removed.id
? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } }
: rule;
};
setDraft((current) => {
const source = asRecord(current ?? {});
const currentAttachments = asRecord(source.attachments);
const currentEntries = asRecord(source.entries);
return {
...source,
attachments: {
...currentAttachments,
zip: { ...zipConfig, archives },
global: asArray(currentAttachments.global).map(resetRule)
},
entries: {
...currentEntries,
inline: asArray(currentEntries.inline).map((value) => {
const entry = asRecord(value);
return { ...entry, attachments: asArray(entry.attachments).map(resetRule) };
})
}
};
});
markDirty();
}
function moveZipArchive(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) });
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Attachments</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Attachment sources">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
className="attachment-sources-table-wrap attachment-sources-table"
/>
</Card>
<Card title="ZIP attachments" collapsible>
<div className="attachment-zip-master-toggle">
<ToggleSwitch
label="Enable ZIP attachments"
checked={zipConfig.enabled}
disabled={locked}
onChange={setZipEnabled}
/>
</div>
<DataGrid
id={`campaign-${campaignId}-zip-archives`}
rows={zipConfig.archives}
columns={zipArchiveColumns({
disabled: locked || !zipConfig.enabled,
archives: zipConfig.archives,
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
onEditName: setZipNameEditorIndex,
passwordFields,
patchArchive: patchZipArchive,
setStandard: setStandardZipArchive,
addArchive: addZipArchive,
moveArchive: moveZipArchive,
removeArchive: removeZipArchive
})}
getRowKey={(archive) => archive.id}
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
className="attachment-zip-table-wrap"
/>
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && (
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert>
)}
{zipArchiveNameValidation.message && (
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
)}
<p className="muted small-note">Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.</p>
</Card>
<Card title="Global Attachments">
<AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
disabled={locked}
emptyText="No global attachments are configured yet. Add files here only if every message should include them."
basePaths={basePaths}
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
</Card>
<Card title="Statistics">
<dl className="detail-list">
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
<div><dt>Upload support</dt><dd>Connected through Files</dd></div>
</dl>
<p className="muted small-note">Files are now managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build.</p>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</>
</LoadingFrame>
<TemplateExpressionEditorDialog
open={zipNameEditorIndex !== null && Boolean(zipConfig.archives[zipNameEditorIndex])}
title="Edit ZIP archive filename"
value={zipNameEditorIndex !== null ? (zipConfig.archives[zipNameEditorIndex]?.name ?? "") : ""}
localFields={filenameFieldOptions.filter((field) => field.namespace === "local").map(({ name, label }) => ({ name, label }))}
globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))}
placeholder="attachments.zip"
description="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted."
saveLabel="Save filename"
validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)}
onCancel={() => setZipNameEditorIndex(null)}
onSave={(name) => {
if (zipNameEditorIndex === null) return;
patchZipArchive(zipNameEditorIndex, { name });
setZipNameEditorIndex(null);
}}
onAddField={(name) => {
if (!draft || locked || !name) return;
const existingFields = asArray(draft.fields).map(asRecord);
if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return;
patch(["fields"], [
...existingFields,
{ name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }
]);
}}
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/>
{pathChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
mode="folder"
source={basePaths[pathChooser.index]?.source}
basePath={basePaths[pathChooser.index]?.path}
onClose={() => setPathChooser(null)}
onSelectFolder={(selection) => {
const current = basePaths[pathChooser.index];
const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label;
patchBasePath(pathChooser.index, {
source: selection.source,
path: selection.folderPath || ".",
name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files"
? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}`
: current.name
});
setPathChooser(null);
}}
/>
)}
<ConfirmDialog
open={Boolean(individualDisable)}
title="Disable individual attachments for this source?"
message={individualDisable
? `${individualDisable.usageCount} individual attachment ${individualDisable.usageCount === 1 ? "entry uses" : "entries use"} this source. Disabling it will remove those recipient-specific attachment entries.`
: ""}
confirmLabel="Remove individual attachments"
cancelLabel="Cancel"
tone="danger"
onConfirm={confirmIndividualDisable}
onCancel={() => setIndividualDisable(null)}
/>
</div>
);
}
type ZipArchiveColumnContext = {
disabled: boolean;
archives: AttachmentZipArchive[];
invalidNameIndexes: ReadonlySet<number>;
onEditName: (index: number) => void;
passwordFields: ReturnType<typeof getDraftFields>;
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
setStandard: (index: number) => void;
addArchive: (afterIndex?: number) => void;
moveArchive: (index: number, targetIndex: number) => void;
removeArchive: (index: number) => void;
};
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
return [
{
id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start",
render: (archive, index) => (
<button
type="button"
className="attachment-zip-name-button"
disabled={disabled}
aria-invalid={invalidNameIndexes.has(index) || undefined}
aria-label={`Edit ZIP archive filename ${archive.name || index + 1}`}
title={invalidNameIndexes.has(index) ? "Archive filenames must be present and unique." : "Edit ZIP archive filename"}
onClick={() => onEditName(index)}
>
<span className="attachment-zip-name-value">{archive.name || "Unnamed ZIP attachment"}</span>
<Pencil size={15} aria-hidden="true" />
</button>
),
value: (archive) => archive.name
},
{
id: "standard", header: "Standard", width: 150, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "standard", label: "Standard" }, { value: "optional", label: "Optional" }] },
render: (archive, index) => <ToggleSwitch label="Standard" checked={archive.standard} disabled={disabled} onChange={(checked) => checked && setStandard(index)} />,
value: (archive) => archive.standard ? "standard" : "optional"
},
{
id: "password_enabled", header: "Password", width: 165, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "protected", label: "Protected" }, { value: "none", label: "No password" }] },
render: (archive, index) => <ToggleSwitch label="Protected" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
value: (archive) => archive.password_enabled ? "protected" : "none"
},
{
id: "password_field", header: "Password field", width: 230, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "", label: "No field" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
render: (archive, index) => (
<select value={archive.password_field} disabled={disabled || !archive.password_enabled || passwordFields.length === 0} onChange={(event) => patchArchive(index, { password_field: event.target.value })}>
<option value="">Select password field</option>
{passwordFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
),
value: (archive) => archive.password_field
},
{
id: "password_scope", header: "Value scope", width: 190, sortable: true, filterable: true,
columnType: "from-list", list: { options: [{ value: "local", label: "Recipient / local" }, { value: "global", label: "Campaign / global" }] },
render: (archive, index) => (
<select value={archive.password_scope} disabled={disabled || !archive.password_enabled} onChange={(event) => patchArchive(index, { password_scope: event.target.value === "global" ? "global" : "local" })}>
<option value="local">Recipient / local</option>
<option value="global">Campaign / global</option>
</select>
),
value: (archive) => archive.password_scope
},
{
id: "actions", header: "Actions", width: 180, sticky: "end",
render: (_archive, index) => <DataGridRowActions
disabled={disabled}
onAddBelow={() => addArchive(index)}
onRemove={() => removeArchive(index)}
onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined}
onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined}
addLabel="Add ZIP attachment below"
removeLabel="Remove ZIP attachment"
moveUpLabel="Move ZIP attachment up"
moveDownLabel="Move ZIP attachment down"
/>
}
];
}
type ZipFilenameNamespace = "local" | "global";
type ZipFilenameFieldOption = {
namespace: ZipFilenameNamespace;
name: string;
label: string;
};
type ZipArchiveNameValidation = {
invalidIndexes: ReadonlySet<number>;
message: string;
};
const EMPTY_ZIP_ARCHIVE_NAME_VALIDATION: ZipArchiveNameValidation = {
invalidIndexes: new Set<number>(),
message: ""
};
function buildZipFilenameFieldOptions(draft: Record<string, unknown>): ZipFilenameFieldOption[] {
const fieldDefinitions = new Map(getDraftFields(draft).map((field) => [field.name, field]));
const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password");
const labels = new Map(filenameFields.map((field) => [field.name, field.label || field.name]));
const localNames = uniqueStrings(["id", "name", "email", ...recipientAddressTemplateFieldOptions().map((field) => field.name), ...filenameFields.map((field) => field.name)]);
const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))])
.filter((name) => fieldDefinitions.get(name)?.type !== "password");
return [
...localNames.map((name) => ({ namespace: "local" as const, name, label: labels.get(name) || humanizeFieldName(name) })),
...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) }))
];
}
function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string {
const normalized = normalizeZipArchiveName(value);
if (!normalized) return "Archive filename must not be empty.";
const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized);
return duplicate ? "Another ZIP attachment already uses this effective filename." : "";
}
function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation {
const invalidIndexes = new Set<number>();
const byName = new Map<string, number[]>();
archives.forEach((archive, index) => {
const normalized = normalizeZipArchiveName(archive.name);
if (!normalized) {
invalidIndexes.add(index);
return;
}
const indexes = byName.get(normalized) ?? [];
indexes.push(index);
byName.set(normalized, indexes);
});
for (const indexes of byName.values()) {
if (indexes.length < 2) continue;
indexes.forEach((index) => invalidIndexes.add(index));
}
if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION;
const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim());
const duplicateNames = [...byName.entries()]
.filter(([, indexes]) => indexes.length > 1)
.map(([, indexes]) => archives[indexes[0]]?.name.trim())
.filter(Boolean);
const parts: string[] = [];
if (hasEmpty) parts.push("Archive filenames must not be empty");
if (duplicateNames.length > 0) parts.push(`Archive filenames must be unique: ${duplicateNames.join(", ")}`);
return { invalidIndexes, message: `${parts.join(". ")}. Saving is disabled until this is corrected.` };
}
function normalizeZipArchiveName(value: string): string {
const trimmed = value.trim().toLocaleLowerCase();
if (!trimmed) return "";
return trimmed.endsWith(".zip") ? trimmed : `${trimmed}.zip`;
}
function uniqueStrings(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
type AttachmentSourceColumnContext = {
locked: boolean;
basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[];
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
setIndividualEligibility: (index: number, checked: boolean) => void;
addBasePath: (afterIndex?: number) => void;
moveBasePath: (index: number, targetIndex: number) => void;
removeBasePath: (index: number) => void;
setPathChooser: (state: PathChooserState | null) => void;
};
function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{
id: "path",
header: "Path",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (basePath, index) => (
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={formatAttachmentSourcePath(basePath, fileSpaces)}
disabled={locked}
readOnly
tabIndex={-1}
placeholder="attachments"
onClick={() => !locked && setPathChooser({ index })}
onKeyDown={(event) => {
if (!locked && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
setPathChooser({ index });
}
}}
/>
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>
</div>
),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)
},
{ id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "Individual" }, { value: "global only", label: "Global only" }] }, render: (basePath, index) => <ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" },
{ id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "Warn" }, { value: "off", label: "Off" }] }, render: (basePath, index) => <ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" },
{
id: "actions",
header: "Actions",
width: 180,
sticky: "end",
render: (_basePath, index) => (
<DataGridRowActions
disabled={locked}
removeDisabled={basePaths.length <= 1}
onAddBelow={() => addBasePath(index)}
onRemove={() => removeBasePath(index)}
onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined}
onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined}
addLabel="Add attachment source below"
removeLabel="Remove attachment source"
moveUpLabel="Move attachment source up"
moveDownLabel="Move attachment source down"
/>
)
}
];
}
function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string {
const parsedSource = parseManagedAttachmentSource(basePath.source);
const matchingSpace = parsedSource
? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId)
: undefined;
const rootLabel = matchingSpace?.label
|| (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : "");
const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, "");
if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`;
return `${relativePath || "."}/`;
}

View File

@@ -0,0 +1,35 @@
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Audit log</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading audit data…">
<Card title="Recent audit events">
<p className="muted">Campaign-specific audit API integration will be added in the audit section pass.</p>
</Card>
</LoadingFrame>
</div>
);
}

View File

@@ -0,0 +1,308 @@
import { useMemo, useRef } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView";
import { getBool, getText, updateNested } from "./utils/draftEditor";
import FieldValueInput from "./components/FieldValueInput";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const fieldValueKeys = useRef<string[]>([]);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "campaign-fields",
unsavedTitle: "Unsaved fields",
unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.",
transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)),
onLoaded: (_loadedVersion, loadedDraft) => {
fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name);
}
});
const fields = useMemo(() => normalizeFields(displayDraft.fields), [displayDraft.fields]);
const globalValues = asRecord(displayDraft.global_values);
const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]);
const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning;
function patchDraft(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
markDirty();
}
function patchFields(nextFields: CampaignFieldDefinition[]) {
patchDraft(["fields"], nextFields.map((field) => ({
name: field.name,
type: field.type || "string",
label: field.label,
required: field.required,
can_override: field.can_override
})));
}
function patchGlobalValues(nextValues: Record<string, unknown>) {
patchDraft(["global_values"], nextValues);
}
function setField(index: number, patchValue: Partial<CampaignFieldDefinition>) {
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field);
patchFields(nextFields);
}
function renameField(index: number, nextName: string) {
const oldName = fields[index]?.name ?? "";
const cleanedName = nextName.trim();
const duplicate = Boolean(cleanedName) && fields.some((field, currentIndex) => currentIndex !== index && field.name === cleanedName);
const valueKey = fieldValueKeys.current[index] ?? oldName;
const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, name: cleanedName } : field);
const nextGlobalValues = { ...globalValues };
if (!duplicate && cleanedName) {
if (valueKey && valueKey !== cleanedName && Object.prototype.hasOwnProperty.call(nextGlobalValues, valueKey)) {
nextGlobalValues[cleanedName] = nextGlobalValues[valueKey];
delete nextGlobalValues[valueKey];
}
fieldValueKeys.current[index] = cleanedName;
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
const nextWithGlobalValues = updateNested(nextDraft, ["global_values"], nextGlobalValues);
if (duplicate || !cleanedName || !valueKey || valueKey === cleanedName) {
return nextWithGlobalValues;
}
return migrateEntryFieldValues(nextWithGlobalValues, valueKey, cleanedName);
});
markDirty();
}
function addField(afterIndex = fields.length - 1) {
const name = uniqueFieldName(fields);
const nextField = { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true };
const nextFields = insertAfter(fields, afterIndex, nextField);
fieldValueKeys.current = insertAfter(fieldValueKeys.current, afterIndex, name);
const nextGlobalValues = { ...globalValues, [name]: "" };
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
markDirty();
}
function moveField(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
fieldValueKeys.current = moveArrayItem(fieldValueKeys.current, index, targetIndex);
patchFields(moveArrayItem(fields, index, targetIndex));
}
function deleteField(index: number) {
const field = fields[index];
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index);
fieldValueKeys.current = fieldValueKeys.current.filter((_, currentIndex) => currentIndex !== index);
const nextGlobalValues = { ...globalValues };
if (field?.name) {
delete nextGlobalValues[field.name];
}
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
});
markDirty();
}
function setGlobalValue(key: string, value: unknown) {
patchGlobalValues({ ...globalValues, [key]: value });
}
function setOverrideAllowed(index: number, allowed: boolean) {
setField(index, { can_override: allowed });
}
async function saveFields(): Promise<boolean> {
const fieldProblem = describeFieldNameProblem(fields);
if (fieldProblem) {
setLocalError(fieldProblem);
setSaveState("Save blocked");
return false;
}
return saveDraft("manual");
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Fields</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<div className="admin-table-surface campaign-fields-table-surface">
<DataGrid
id={`campaign-${campaignId}-fields`}
rows={fields}
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
getRowKey={(_field, index) => `field-row-${index}`}
emptyText="No campaign fields configured yet."
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
className="field-editor-table-wrap field-editor-table"
/>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
type FieldColumnContext = {
locked: boolean;
fields: CampaignFieldDefinition[];
globalValues: Record<string, unknown>;
renameField: (index: number, nextName: string) => void;
setField: (index: number, patchValue: Partial<CampaignFieldDefinition>) => void;
setGlobalValue: (key: string, value: unknown) => void;
setOverrideAllowed: (index: number, allowed: boolean) => void;
addField: (afterIndex?: number) => void;
moveField: (index: number, targetIndex: number) => void;
deleteField: (index: number) => void;
};
function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn<CampaignFieldDefinition>[] {
return [
{ id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => <input value={field.name} disabled={locked} placeholder="field_name" onChange={(event) => renameField(index, event.target.value)} />, value: (field) => field.name },
{ id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => <input value={field.label} disabled={locked} placeholder="Display label" onChange={(event) => setField(index, { label: event.target.value })} />, value: (field) => field.label },
{ id: "type", header: "Type", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => <select value={field.type} disabled={locked} onChange={(event) => setField(index, { type: normalizeFieldType(event.target.value) })}>{fieldTypeOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>, value: (field) => field.type },
{ id: "required", header: "Required", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (field, index) => <ToggleSwitch label="Required" checked={field.required} disabled={locked} onChange={(checked) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" },
{ id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => <FieldValueInput fieldType={field.type} value={globalValues[field.name]} disabled={locked || !field.name} placeholder="Optional default" onChange={(value) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") },
{ id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can override", label: "Can override" }, { value: "locked", label: "Global only" }] }, render: (field, index) => <ToggleSwitch label="Can override" checked={field.can_override} disabled={locked || !field.name} onChange={(checked) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" },
{
id: "actions",
header: "Actions",
width: 180,
sticky: "end",
render: (_field, index) => (
<DataGridRowActions
disabled={locked}
onAddBelow={() => addField(index)}
onRemove={() => deleteField(index)}
onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined}
onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined}
addLabel="Add field below"
removeLabel="Remove field"
moveUpLabel="Move field up"
moveDownLabel="Move field down"
/>
)
}
];
}
function normalizeFields(value: unknown): CampaignFieldDefinition[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((field) => ({
name: getText(field, "name"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type")),
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}));
}
function migrateEntryFieldValues(draft: Record<string, unknown>, oldName: string, newName: string): Record<string, unknown> {
const entries = asRecord(draft.entries);
const inlineEntries = Array.isArray(entries.inline) ? entries.inline : [];
if (inlineEntries.length === 0) return draft;
const nextInlineEntries = inlineEntries.map((entry) => {
if (!isRecord(entry)) return entry;
const fields = asRecord(entry.fields);
if (!Object.prototype.hasOwnProperty.call(fields, oldName)) return entry;
const nextFields = { ...fields };
if (!Object.prototype.hasOwnProperty.call(nextFields, newName)) {
nextFields[newName] = nextFields[oldName];
}
delete nextFields[oldName];
return { ...entry, fields: nextFields };
});
return updateNested(draft, ["entries", "inline"], nextInlineEntries);
}
function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState: Record<string, unknown>): Record<string, unknown> {
const overridePolicy = asRecord(editorState.field_overrides);
if (Object.keys(overridePolicy).length === 0) return draft;
const fields = normalizeFields(draft.fields).map((field) => {
if (!field.name || !Object.prototype.hasOwnProperty.call(overridePolicy, field.name)) return field;
return { ...field, can_override: getBool(overridePolicy, field.name, true) };
});
return updateNested(draft, ["fields"], fields);
}
function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string {
const names = fields.map((field) => field.name.trim());
if (names.some((name) => !name)) {
return "Field IDs must not be empty before saving.";
}
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
if (duplicates.size === 0) return "";
return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`;
}
function uniqueFieldName(fields: CampaignFieldDefinition[]): string {
const existing = new Set(fields.map((field) => field.name));
let counter = fields.length + 1;
let name = `field_${counter}`;
while (existing.has(name)) {
counter += 1;
name = `field_${counter}`;
}
return name;
}

View File

@@ -0,0 +1,39 @@
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, getCampaignJson } from "./utils/campaignView";
import { downloadJson, safeFileStem } from "./utils/draftEditor";
export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const campaignJson = getCampaignJson(version);
const campaign = asRecord(campaignJson.campaign);
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>JSON</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>Download JSON</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading JSON…">
<Card>
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
</Card>
</LoadingFrame>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Card from "../../components/Card";
import Button from "../../components/Button";
import StatusBadge from "../../components/StatusBadge";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { createNewCampaign, listCampaigns } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
export default function CampaignListPage({ settings }: { settings: ApiSettings }) {
const navigate = useNavigate();
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
async function load() {
setLoading(true);
setError("");
try {
const data = await listCampaigns(settings);
setCampaigns(data);
setLastLoadedAt(formatLoadedAt(new Date()));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}
async function create() {
setCreating(true);
setError("");
try {
const created = await createNewCampaign(settings);
navigate(`/campaigns/${created.campaign.id}/wizard/create`);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setCreating(false);
}
}
useEffect(() => {
load();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const columns: DataGridColumn<CampaignListItem>[] = [
{
id: "campaign",
header: "Campaign",
width: "minmax(260px, 1.8fr)",
sortable: true,
filterable: true,
sticky: "start",
render: (campaign) => (
<div>
<Link className="table-primary-link" to={`/campaigns/${campaign.id}`}>
{campaign.name || campaign.external_id || campaign.id}
</Link>
<div className="table-subline">{campaign.description || campaign.external_id || campaign.id}</div>
</div>
),
value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}`
},
{
id: "status",
header: "Status",
width: 150,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "draft", label: "Draft" },
{ value: "active", label: "Active" },
{ value: "completed", label: "Completed" },
{ value: "archived", label: "Archived" }
],
display: "pill"
},
render: (campaign) => <StatusBadge status={campaign.status || "draft"} />,
value: (campaign) => campaign.status || "draft"
},
{
id: "current_version",
header: "Current version",
width: 170,
sortable: true,
filterable: true,
className: "version-cell mono-small",
render: (campaign) => <span title={campaign.current_version_id || undefined}>{campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}</span>,
value: (campaign) => campaign.current_version_id ?? ""
},
{
id: "updated",
header: "Updated",
width: 190,
sortable: true,
filterable: true,
filterType: "date",
className: "updated-cell",
render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at),
value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? ""
},
{
id: "actions",
header: "Actions",
width: 110,
sticky: "end",
render: (campaign) => <Link to={`/campaigns/${campaign.id}`}><Button variant="primary">Open</Button></Link>
}
];
return (
<div className="content-pad campaigns-page">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>All campaigns</PageTitle>
<p className="mono-small">{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={load} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "Creating…" : "New campaign"}
</Button>
</div>
</div>
<Card>
<LoadingFrame loading={loading} label="Loading campaigns…">
{campaigns.length === 0 ? (
<div className="empty-state">
<h2>No campaigns yet</h2>
<p>Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.</p>
<Button variant="primary" onClick={create} disabled={creating}>
Create first campaign
</Button>
</div>
) : (
<DataGrid
id="campaigns"
rows={campaigns}
columns={columns}
getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }}
className="campaign-table-wrap"
emptyText="No campaigns found."
/>
)}
</LoadingFrame>
</Card>
</div>
);
}
function shortId(value: string): string {
if (value.length <= 20) return value;
return `${value.slice(0, 12)}${value.slice(-6)}`;
}
function formatDateTime(value?: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
function formatLoadedAt(value: Date): string {
return value.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}

View File

@@ -0,0 +1,281 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import ConfirmDialog from "../../components/ConfirmDialog";
import FormField from "../../components/FormField";
import LoadingFrame from "../../components/LoadingFrame";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import {
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock,
updateCampaignMetadata,
type CampaignVersionListItem,
} from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
isVersionReadyForDelivery,
summaryValue,
} from "./utils/campaignView";
const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null;
export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const campaign = data.campaign;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
const [identityDirty, setIdentityDirty] = useState(false);
const [savingIdentity, setSavingIdentity] = useState(false);
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
if (!campaign || identityDirty) return;
setIdentity({
external_id: campaign.external_id ?? "",
name: campaign.name ?? "",
status: campaign.status ?? "",
description: campaign.description ?? "",
});
}, [campaign, identityDirty]);
function patchIdentity(key: keyof typeof identity, value: string) {
setIdentity((current) => ({ ...current, [key]: value }));
setIdentityDirty(true);
setMessage("");
}
async function saveIdentity() {
if (!campaign || savingIdentity || !identityDirty) return;
setSavingIdentity(true);
setError("");
setMessage("");
try {
await updateCampaignMetadata(settings, campaign.id, {
external_id: identity.external_id,
name: identity.name,
status: identity.status,
description: identity.description,
});
setIdentityDirty(false);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSavingIdentity(false);
}
}
async function applyLockAction() {
const pending = pendingLockAction;
if (!pending || lockBusy) return;
setLockBusy(true);
setError("");
setMessage("");
try {
if (pending.action === "temporary") {
await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} temporarily locked.`);
} else if (pending.action === "unlock") {
await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id);
setMessage(`Temporary lock removed from version #${pending.version.version_number}.`);
} else {
await lockCampaignVersionPermanently(settings, campaignId, pending.version.id);
setMessage(`Version #${pending.version.version_number} permanently locked.`);
}
setPendingLockAction(null);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLockBusy(false);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{campaign?.name || "Overview"}</PageTitle>
<p className="mono-small">Campaign overview · version-independent identity and version history</p>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>Reload</Button>
<Link to="wizard/create"><Button>Edit with wizard</Button></Link>
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "Saving…" : "Save"}</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading campaign overview…">
<div className="metric-grid campaign-overview-metrics">
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
<MetricCard label="Sent" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="SMTP success" />
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Campaign identity">
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={identity.status} onChange={(event) => patchIdentity("status", event.target.value)}>
{campaignModeOptions.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
<FormField label="Name">
<input value={identity.name} onChange={(event) => patchIdentity("name", event.target.value)} />
</FormField>
<FormField label="Description">
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
</FormField>
</div>
</Card>
<Card title="Version history">
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="No versions found."
className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
/>
</Card>
<Card title="Current version state">
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
</div>
</Card>
</LoadingFrame>
<ConfirmDialog
open={Boolean(pendingLockAction)}
title={lockDialogTitle(pendingLockAction)}
message={lockDialogMessage(pendingLockAction)}
confirmLabel={lockDialogLabel(pendingLockAction)}
tone={pendingLockAction?.action === "unlock" ? "default" : "danger"}
busy={lockBusy}
onCancel={() => setPendingLockAction(null)}
onConfirm={() => void applyLockAction()}
/>
</div>
);
}
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "State", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
{ id: "lock", header: "Lock", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Current working version", label: "Current working version" }, { value: "Historical version", label: "Historical version" }, { value: "Temporarily locked", label: "Temporarily locked" }, { value: "Permanently locked", label: "Permanently locked" }, { value: "Delivery locked", label: "Delivery locked" }] }, render: (version) => versionLockLabel(version, currentVersionId), value: (version) => versionLockLabel(version, currentVersionId) },
{ id: "validation", header: "Validation", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not validated", label: "Not validated" }, { value: "Valid", label: "Valid" }, { value: "Validation issues", label: "Validation issues" }] }, render: validationLabel, value: validationLabel },
{ id: "build", header: "Build", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Not built", label: "Not built" }, { value: "Built", label: "Built" }, { value: "Build issues", label: "Build issues" }] }, render: buildLabel, value: buildLabel },
{ id: "updated", header: "Updated", width: 190, sortable: true, filterable: true, filterType: "date", render: (version) => formatDateTime(version.updated_at), value: (version) => version.updated_at ?? "" },
{
id: "actions",
header: "Actions",
width: 310,
sticky: "end",
render: (version) => {
const isCurrent = version.id === currentVersionId;
return (
<div className="button-row compact-actions">
<Link to={`send?version=${version.id}`}><Button variant={isCurrent ? "primary" : "secondary"}>Open</Button></Link>
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
<>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
</>
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
) : null)}
</div>
);
},
},
];
}
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
if (currentVersionId && version.id !== currentVersionId) return "Historical · review-only";
if (isTemporaryUserLockedVersion(version)) return "Temporary user lock";
if (isPermanentUserLockedVersion(version)) return "Permanent user lock";
if (isFinalLockedVersion(version)) return "Permanent delivery lock";
if (canUnlockValidationVersion(version)) return "Temporary validation lock";
if (version.locked_at) return "Temporarily locked";
return "Editable";
}
function validationLabel(version: CampaignVersionListItem): string {
const validation = version.validation_summary ?? {};
if (validation.ok === true && isVersionReadyForDelivery(version)) return "Passed";
if (validation.ok === false) return "Needs attention";
if (validation.ok === true) return "Passed, unavailable while user-locked";
return "Not validated";
}
function buildLabel(version: CampaignVersionListItem): string {
const build = version.build_summary ?? {};
return String(build.built_count ?? build.ready_count ?? "Not built");
}
function lockDialogTitle(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Temporarily lock version?";
if (pending?.action === "unlock") return "Unlock version?";
if (pending?.action === "permanent") return "Lock version permanently?";
return "Confirm lock action";
}
function lockDialogMessage(pending: PendingLockAction): string {
if (pending?.action === "temporary") {
return "This makes the version read-only without making it final. An authorized user may unlock it later or make the lock permanent.";
}
if (pending?.action === "unlock") {
return "This removes the temporary user lock and makes the version editable again. Existing validation/build state is otherwise retained.";
}
if (pending?.action === "permanent") {
return "This lock cannot be removed by any role. The version remains reviewable for audit purposes; future changes require an editable copy.";
}
return "Continue?";
}
function lockDialogLabel(pending: PendingLockAction): string {
if (pending?.action === "temporary") return "Lock temporarily";
if (pending?.action === "unlock") return "Unlock";
if (pending?.action === "permanent") return "Lock permanently";
return "Confirm";
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
return (
<div className="summary-tile">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}

View File

@@ -0,0 +1,357 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import {
downloadCampaignJobsCsv,
emailCampaignReport,
getCampaignJobDetail,
getCampaignJobs,
resolveCampaignJobOutcome,
retryCampaignJobs,
sendUnattemptedCampaignJobs,
type CampaignJobDetailResponse,
type CampaignJobsResponse,
} from "../../api/campaigns";
import Card from "../../components/Card";
import Button from "../../components/Button";
import ConfirmDialog from "../../components/ConfirmDialog";
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import VersionLine from "./components/VersionLine";
import LoadingFrame from "../../components/LoadingFrame";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView";
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"not_queued",
"queued",
"claimed",
"sending",
"smtp_accepted",
"sent",
"outcome_unknown",
"failed_temporary",
"failed_permanent",
"cancelled",
].map((value) => ({ value, label: humanize(value) }));
type ReconcileRequest = { jobId: string; decision: "smtp_accepted" | "not_sent" } | null;
export default function CampaignReportPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const version = data.currentVersion;
const cards = data.summary?.cards;
const delivery = asRecord(data.summary?.delivery);
const rateLimit = asRecord(delivery.rate_limit);
const imapPolicy = asRecord(delivery.imap_append_sent);
const [jobs, setJobs] = useState<CampaignJobsResponse>({
jobs: [],
page: 1,
page_size: 50,
total: 0,
total_unfiltered: 0,
pages: 0,
counts: {},
filtered_counts: {},
review: {},
});
const [jobsLoading, setJobsLoading] = useState(false);
const [page, setPage] = useState(1);
const [sendStatus, setSendStatus] = useState("");
const [query, setQuery] = useState("");
const [appliedQuery, setAppliedQuery] = useState("");
const [actionMessage, setActionMessage] = useState("");
const [actionError, setActionError] = useState("");
const [busyAction, setBusyAction] = useState("");
const [detail, setDetail] = useState<CampaignJobDetailResponse | null>(null);
const [emailOpen, setEmailOpen] = useState(false);
const [emailRecipients, setEmailRecipients] = useState("");
const [attachCsv, setAttachCsv] = useState(true);
const [attachJson, setAttachJson] = useState(false);
const [reconcile, setReconcile] = useState<ReconcileRequest>(null);
useEffect(() => {
const handle = window.setTimeout(() => {
setAppliedQuery(query.trim());
setPage(1);
}, 350);
return () => window.clearTimeout(handle);
}, [query]);
useEffect(() => {
void loadJobs();
}, [campaignId, version?.id, page, sendStatus, appliedQuery]);
async function loadJobs() {
if (!campaignId) return;
setJobsLoading(true);
setActionError("");
try {
const response = await getCampaignJobs(settings, campaignId, {
versionId: version?.id,
page,
pageSize: 50,
sendStatus: sendStatus ? [sendStatus] : undefined,
query: appliedQuery || undefined,
});
setJobs(response);
if (response.pages > 0 && page > response.pages) setPage(response.pages);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setJobsLoading(false);
}
}
async function reloadAll() {
await Promise.all([reload(), loadJobs()]);
}
async function runExplicitAction(action: "retry" | "unattempted") {
if (!version || busyAction) return;
setBusyAction(action);
setActionError("");
setActionMessage("");
try {
const response = action === "retry"
? await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true })
: await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
const result = asRecord(response.result ?? response);
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
await reloadAll();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
async function reconcileOutcome() {
if (!reconcile || busyAction) return;
setBusyAction("reconcile");
setActionError("");
try {
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
setActionMessage(reconcile.decision === "smtp_accepted"
? "The job was recorded as SMTP accepted and is protected from retry."
: "The job was recorded as not sent. It is now an explicit retry candidate.");
setReconcile(null);
await reloadAll();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
async function openJob(jobId: string) {
setBusyAction("detail");
setActionError("");
try {
setDetail(await getCampaignJobDetail(settings, campaignId, jobId));
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
async function exportCsv() {
setBusyAction("csv");
setActionError("");
try {
await downloadCampaignJobsCsv(settings, campaignId, version?.id);
setActionMessage("Campaign job CSV downloaded.");
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
async function sendReportEmail() {
const recipients = emailRecipients.split(/[;,\n]+/).map((value) => value.trim()).filter(Boolean);
if (recipients.length === 0 || busyAction) return;
setBusyAction("email");
setActionError("");
try {
await emailCampaignReport(settings, campaignId, {
to: recipients,
version_id: version?.id,
include_jobs: false,
attach_jobs_csv: attachCsv,
attach_report_json: attachJson,
});
setActionMessage(`Report sent to ${recipients.join(", ")}.`);
setEmailOpen(false);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setBusyAction("");
}
}
const columns = useMemo<DataGridColumn<Record<string, unknown>>[]>(() => [
{ id: "number", header: "#", width: 70, sticky: "start", sortable: true, value: (row) => Number(row.entry_index ?? 0) + 1 },
{ id: "recipient", header: "Recipient", width: 240, resizable: true, sortable: true, filterable: true, value: (row) => String(row.recipient_email ?? "—") },
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
{ id: "send", header: "SMTP", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, value: (row) => String(row.send_status ?? "unknown") },
{ id: "imap", header: "IMAP", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
{ id: "attempts", header: "Attempts", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
{ id: "error", header: "Last result", width: "minmax(220px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "—") },
{
id: "actions",
header: "Actions",
width: 250,
sticky: "end",
render: (row) => {
const id = String(row.id ?? "");
const status = String(row.send_status ?? "");
return (
<div className="button-row compact-actions">
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>Details</Button>
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>Accepted</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>Not sent</Button>}
</div>
);
},
},
], [busyAction]);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Report</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>Download CSV</Button>
<Button onClick={() => setEmailOpen(true)}>Email report</Button>
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Reload</Button>
</div>
</div>
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading report data…">
<div className="dashboard-grid">
<Card title="Delivery outcome">
<dl className="detail-list">
<div><dt>Generated</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
<div><dt>Jobs total</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
<div><dt>SMTP accepted</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
<div><dt>Failed</dt><dd>{cards?.failed ?? 0}</dd></div>
<div><dt>Outcome unknown</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
<div><dt>Not attempted</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
<div><dt>Cancelled</dt><dd>{cards?.cancelled ?? 0}</dd></div>
</dl>
</Card>
<Card title="IMAP and execution plan">
<dl className="detail-list">
<div><dt>IMAP appended</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
<div><dt>IMAP failed</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
<div><dt>Append policy</dt><dd>{imapPolicy.enabled === true ? `Enabled (${String(imapPolicy.folder ?? "auto")})` : "Disabled"}</dd></div>
<div><dt>Rate limit</dt><dd>{rateLimit.messages_per_minute ? `${String(rateLimit.messages_per_minute)} / minute` : "—"}</dd></div>
<div><dt>Minimum remaining duration</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
<div><dt>Execution snapshot</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? `${String(delivery.execution_snapshot_hash).slice(0, 12)}` : "Missing"}</dd></div>
</dl>
</Card>
<Card title="Explicit delivery actions">
<p className="muted">These actions never include SMTP-accepted or unresolved jobs. Uncertain outcomes must be reconciled first.</p>
<div className="button-row compact-actions">
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>Retry temporary failures</Button>
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>Send unattempted jobs</Button>
</div>
</Card>
</div>
<Card title="Recipient delivery jobs">
<div className="page-heading split">
<div className="button-row compact-actions">
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search recipient, subject or entry ID" />
<select value={sendStatus} onChange={(event) => { setSendStatus(event.target.value); setPage(1); }}>
<option value="">All SMTP states</option>
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
<span className="muted">{jobs.total} matching of {jobs.total_unfiltered} total job(s)</span>
</div>
<LoadingFrame loading={jobsLoading} label="Loading delivery jobs…">
<DataGrid<Record<string, unknown>>
id={`campaign-report-jobs-${campaignId}`}
rows={jobs.jobs}
columns={columns}
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
emptyText="No jobs match the current filters."
/>
</LoadingFrame>
<div className="button-row compact-actions">
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>Previous</Button>
<span>Page {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>Next</Button>
</div>
</Card>
</LoadingFrame>
<Dialog
open={emailOpen}
title="Email campaign report"
onClose={() => setEmailOpen(false)}
closeDisabled={busyAction === "email"}
footer={(
<div className="button-row">
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>Cancel</Button>
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>Send report</Button>
</div>
)}
>
<label className="field-stack">
<span>Recipients</span>
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
</label>
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> Attach job CSV</label>
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> Attach JSON report</label>
</Dialog>
<Dialog
open={Boolean(detail)}
title="Campaign job detail"
onClose={() => setDetail(null)}
className="dialog-panel-wide"
>
{detail && (
<div className="stacked-sections">
<dl className="detail-list">
<div><dt>Recipient</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
<div><dt>Subject</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
<div><dt>SMTP state</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
<div><dt>IMAP state</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
<div><dt>Attachments</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
</dl>
<h3>SMTP attempts</h3>
<pre className="json-preview">{stringifyPreview(detail.attempts.smtp ?? [], 12000)}</pre>
<h3>IMAP attempts</h3>
<pre className="json-preview">{stringifyPreview(detail.attempts.imap ?? [], 12000)}</pre>
</div>
)}
</Dialog>
<ConfirmDialog
open={Boolean(reconcile)}
title={reconcile?.decision === "smtp_accepted" ? "Record SMTP acceptance?" : "Record message as not sent?"}
message={reconcile?.decision === "smtp_accepted"
? "Use this only after checking the SMTP server or recipient-side evidence. The job will be protected from retry and may proceed to IMAP append."
: "Use this only when you have evidence that SMTP did not accept the message. The job will become a failed-temporary retry candidate, but it will not be retried automatically."}
confirmLabel={reconcile?.decision === "smtp_accepted" ? "Record accepted" : "Record not sent"}
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
busy={busyAction === "reconcile"}
onConfirm={() => void reconcileOutcome()}
onCancel={() => setReconcile(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router-dom";
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
import SectionSidebar from "../../layout/SectionSidebar";
import CampaignOverviewPage from "./CampaignOverviewPage";
import CampaignFieldsPage from "./CampaignFieldsPage";
import GlobalSettingsPage from "./GlobalSettingsPage";
import RecipientDataPage from "./RecipientDataPage";
import RecipientDetailsPage from "./RecipientDetailsPage";
import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
import ReviewSendPage from "./ReviewSendPage";
import CreateWizard from "./wizard/CreateWizard";
import ReviewWizard from "./wizard/ReviewWizard";
import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage";
import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
campaign: "recipients",
"global-settings": "global-settings",
fields: "fields",
recipients: "recipients",
"recipient-data": "recipient-data",
template: "template",
files: "files",
"mail-settings": "mail-settings",
review: "review",
report: "report",
audit: "audit",
json: "json"
};
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
return (
<CampaignUnsavedChangesProvider>
<CampaignWorkspaceInner settings={settings} auth={auth} />
</CampaignUnsavedChangesProvider>
);
}
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const { campaignId } = useParams();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const location = useLocation();
const active = sectionFromPath(location.pathname);
const urlVersionId = new URLSearchParams(location.search).get("version");
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(urlVersionId);
useEffect(() => {
if (urlVersionId) setSelectedVersionId(urlVersionId);
}, [urlVersionId]);
// Relative links inside the workspace historically dropped ?version=. Keep
// the selected version stable for the lifetime of this campaign workspace,
// while still opening the latest version on a fresh campaign entry.
useEffect(() => {
if (urlVersionId || !selectedVersionId) return;
const params = new URLSearchParams(location.search);
params.set("version", selectedVersionId);
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
function select(section: CampaignWorkspaceSection) {
const path = sectionPaths[section];
const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
const params = new URLSearchParams(location.search);
const versionId = urlVersionId ?? selectedVersionId;
if (versionId) params.set("version", versionId);
const query = params.toString();
const target = query ? `${pathname}?${query}` : pathname;
if (`${location.pathname}${location.search}` === target) return;
requestNavigation(() => navigate(target));
}
return (
<div className="workspace">
<SectionSidebar active={active} onSelect={select} />
<section className="workspace-content">
<Routes>
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="data" element={<Navigate to="../recipients" replace />} />
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="recipient-data" element={<RecipientDetailsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="attachments" element={<Navigate to="../files" replace />} />
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="review" element={<ReviewSendPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="send" element={<Navigate to="../review" replace />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="reports" element={<Navigate to="../report" replace />} />
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/create" element={<CreateWizard settings={settings} campaignId={campaignId || ""} />} />
<Route path="wizard/review" element={<ReviewWizard />} />
<Route path="wizard/send" element={<SendWizard />} />
<Route path="create" element={<Navigate to="../wizard/create" replace />} />
<Route path="campaign" element={<Navigate to="../data" replace />} />
<Route path="mail" element={<Navigate to="../mail-settings" replace />} />
<Route path="settings" element={<Navigate to="../global-settings" replace />} />
<Route path="Review" element={<Navigate to="../" replace />} />
<Route path="*" element={<Navigate to="../" replace />} />
</Routes>
</section>
</div>
);
}
function sectionFromPath(pathname: string): CampaignWorkspaceSection {
const segments = pathname.split("/").filter(Boolean);
const section = segments[2];
if (!section || section === "wizard" || section === "create") return "overview";
if (section === "data" || section === "campaign") return "recipients";
if (section === "global-settings" || section === "settings") return "global-settings";
if (section === "fields") return "fields";
if (section === "recipients") return "recipients";
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
if (section === "template") return "template";
if (section === "files" || section === "attachments") return "files";
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
if (section === "review") return "review";
if (section === "send") return "review";
if (section === "report" || section === "reports") return "report";
if (section === "audit") return "audit";
if (section === "json") return "json";
return "overview";
}

View File

@@ -0,0 +1,158 @@
import { useState } from "react";
import type { ApiSettings, AuthInfo } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import CampaignAccessCard from "./components/CampaignAccessCard";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { hasScope } from "../../utils/permissions";
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type EditorState = Record<string, unknown>;
export default function GlobalSettingsPage({ settings, auth, campaignId }: { settings: ApiSettings; auth: AuthInfo; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [editorState, setEditorState] = useState<EditorState>({});
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "global-settings",
unsavedTitle: "Unsaved global settings",
unsavedMessage: "Policies have unsaved changes. Save them before leaving, or discard them and continue.",
extraPayload: () => ({ editor_state: editorState }),
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
});
const validationPolicy = asRecord(displayDraft.validation_policy);
const attachments = asRecord(displayDraft.attachments);
const delivery = asRecord(displayDraft.delivery);
const rateLimit = asRecord(delivery.rate_limit);
const retry = asRecord(delivery.retry);
const statusTracking = asRecord(displayDraft.status_tracking);
const optIns = asRecord(editorState.opt_ins);
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
function patchEditor(path: string[], value: unknown) {
if (locked) return;
setEditorState((current) => updateNested(current, path, value));
markDirty();
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Campaign settings</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
{canReadRetentionPolicy && (
<RetentionPolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
title="Campaign retention policy"
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
canWrite={canWriteRetentionPolicy}
locked={locked}
/>
)}
<div className="dashboard-grid">
<Card title="Validation policy">
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
<PolicySelect label="Unsent attachment file" value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />
</Card>
<Card title="Attachment defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Missing behavior">
<select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<FormField label="Ambiguous behavior">
<select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
</div>
<p className="muted small-note">The actual global and per-recipient attachment rules live in Attachments. These settings define campaign-wide behavior used by validation and review. Individual-attachment permission is configured per attachment base path.</p>
</Card>
</div>
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</Card>
<Card title="Opt-ins and local assistance">
<div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
);
}

View File

@@ -0,0 +1,627 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import ToggleSwitch from "../../components/ToggleSwitch";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { MailProfilePolicyEditor } from "../mail/MailProfileManagement";
import {
clearMockMailboxMessages,
createMailServerProfile,
getMailProfilePolicy,
getMockMailboxMessage,
listImapFolders,
listMailProfileImapFolders,
listMailServerProfiles,
listMockMailboxMessages,
testImapSettings,
testMailProfileImap,
testMailProfileSmtp,
testSmtpSettings,
updateMockMailboxFailures,
type MailConnectionTestResponse,
type MailImapFolderListResponse,
type MailProfilePolicy,
type MailSecurity,
type MailServerProfile,
type MockMailboxMessage
} from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor";
const securityOptions = ["plain", "tls", "starttls"];
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null);
const [mockMessages, setMockMessages] = useState<MockMailboxMessage[]>([]);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
const [mockError, setMockError] = useState("");
const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false);
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
const [effectiveMailPolicy, setEffectiveMailPolicy] = useState<MailProfilePolicy | null>(null);
const [profilesLoading, setProfilesLoading] = useState(false);
const [profileName, setProfileName] = useState("");
const [profileMessage, setProfileMessage] = useState("");
const [profileError, setProfileError] = useState("");
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "mail-settings",
unsavedTitle: "Unsaved server settings",
unsavedMessage: "Server settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const server = asRecord(displayDraft.server);
const smtp = asRecord(server.smtp);
const imap = asRecord(server.imap);
const delivery = asRecord(displayDraft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
const imapEnabled = getBool(imap, "enabled");
const selectedProfileId = getText(server, "mail_profile_id");
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
const usingMailProfile = Boolean(selectedProfileId);
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
const effectiveImapAvailable = usingMailProfile ? selectedProfileHasImap : imapEnabled;
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
const smtpDisabled = locked || usingMailProfile;
const smtpCredentialDisabled = locked || (usingMailProfile && smtpCredentialsInherited);
const imapServerDisabled = locked || usingMailProfile || !imapEnabled;
const imapCredentialDisabled = locked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited);
const imapDisabled = locked || !effectiveImapAvailable;
useEffect(() => { void refreshMailProfiles(); }, [settings.apiBaseUrl, settings.apiKey, campaignId]);
async function refreshMailProfiles() {
setProfilesLoading(true);
setProfileError("");
try {
const [allowedProfiles, visibleProfiles, policyResponse] = await Promise.all([
listMailServerProfiles(settings, false, campaignId),
listMailServerProfiles(settings, true),
getMailProfilePolicy(settings, "campaign", campaignId, campaignId)
]);
setMailProfiles(allowedProfiles);
setPolicyProfiles(visibleProfiles);
setEffectiveMailPolicy(policyResponse.effective_policy ?? null);
} catch (err) {
setMailProfiles([]);
setPolicyProfiles([]);
setEffectiveMailPolicy(null);
setProfileError(err instanceof Error ? err.message : String(err));
} finally {
setProfilesLoading(false);
}
}
function selectMailProfile(profileId: string) {
if (locked) return;
patch(["server", "mail_profile_id"], profileId);
setProfileMessage("");
setProfileError("");
if (profileId) {
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
setMockSandboxEnabled(false);
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
}
}
async function saveCurrentSettingsAsProfile() {
if (locked || usingMailProfile) return;
setProfilesLoading(true);
setProfileMessage("");
setProfileError("");
try {
const created = await createMailServerProfile(settings, {
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
scope_type: "campaign",
scope_id: campaignId,
smtp: smtpPayload(),
imap: imapEnabled ? imapPayload() : null,
is_active: true
});
setMailProfiles((current) => [...current.filter((profile) => profile.id !== created.id), created].sort((a, b) => a.name.localeCompare(b.name)));
patch(["server", "mail_profile_id"], created.id);
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
setProfileName("");
setProfileMessage(`Saved profile ${created.name}.`);
} catch (err) {
setProfileError(err instanceof Error ? err.message : String(err));
} finally {
setProfilesLoading(false);
}
}
function toggleImap(enabled: boolean) {
patch(["server", "imap", "enabled"], enabled);
if (!enabled) {
patch(["delivery", "imap_append_sent", "enabled"], false);
}
}
function profileScopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "system";
if (profile.scope_type === "tenant") return "tenant";
if (profile.scope_type === "user") return "user";
if (profile.scope_type === "group") return "group";
return "campaign";
}
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;
return normalized ? normalized : null;
}
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
}
function smtpPayload() {
return {
host: emptyToNull(getText(smtp, "host")),
port: getNumber(smtp, "port", 587),
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false),
security: readSecurity(getText(smtp, "security", "starttls"), "starttls"),
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
};
}
function imapPayload() {
return {
enabled: true,
host: emptyToNull(getText(imap, "host")),
port: getNumber(imap, "port", 993),
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false),
security: readSecurity(getText(imap, "security", "tls"), "tls"),
sent_folder: emptyToNull(getText(imap, "sent_folder", "auto")),
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
};
}
function effectiveSmtpPayload() {
if (selectedProfile && !smtpCredentialsInherited) {
return {
...selectedProfile.smtp,
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false)
};
}
return smtpPayload();
}
function effectiveImapPayload() {
if (selectedProfile?.imap && !imapCredentialsInherited) {
return {
...selectedProfile.imap,
enabled: true,
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false)
};
}
return imapPayload();
}
async function runSmtpTest() {
if (locked) return;
setMailActionState("smtp");
setLocalError("");
try {
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
? await testMailProfileSmtp(settings, selectedProfileId)
: await testSmtpSettings(settings, effectiveSmtpPayload()));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runImapTest() {
if (imapDisabled) return;
setMailActionState("imap");
setLocalError("");
try {
setImapTestResult(selectedProfileId && imapCredentialsInherited
? await testMailProfileImap(settings, selectedProfileId)
: await testImapSettings(settings, effectiveImapPayload()));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runFolderLookup() {
if (imapDisabled) return;
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(selectedProfileId && imapCredentialsInherited
? await listMailProfileImapFolders(settings, selectedProfileId)
: await listImapFolders(settings, effectiveImapPayload()));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
setMailActionState(null);
}
}
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || imapDisabled) return;
if (!usingMailProfile) {
patch(["server", "imap", "sent_folder"], folder);
}
if (!getText(imapAppend, "folder") || getText(imapAppend, "folder") === "auto") {
patch(["delivery", "imap_append_sent", "folder"], folder);
}
}
function toggleMockMailSandbox(enabled: boolean) {
if (locked || usingMailProfile) return;
setMockSandboxEnabled(enabled);
if (enabled) {
mockSandboxSnapshot.current = {
smtp: { ...smtp },
imap: { ...imap },
imap_append_sent: { ...imapAppend }
};
patch(["server", "smtp", "host"], "mock.smtp.local");
patch(["server", "smtp", "port"], 2525);
patch(["server", "smtp", "security"], "plain");
patch(["server", "smtp", "username"], "mock");
patch(["server", "smtp", "password"], "mock");
patch(["server", "imap", "enabled"], true);
patch(["server", "imap", "host"], "mock.imap.local");
patch(["server", "imap", "port"], 1143);
patch(["server", "imap", "security"], "plain");
patch(["server", "imap", "username"], "mock");
patch(["server", "imap", "password"], "mock");
patch(["server", "imap", "sent_folder"], "Sent");
patch(["delivery", "imap_append_sent", "enabled"], true);
patch(["delivery", "imap_append_sent", "folder"], "Sent");
setSmtpTestResult({ ok: true, protocol: "smtp", host: "mock.smtp.local", port: 2525, security: "plain", message: "Temporary mock profile enabled. Turn it off to restore the previous values.", details: { mock: true } });
return;
}
const snapshot = mockSandboxSnapshot.current;
if (snapshot) {
patch(["server", "smtp"], asRecord(snapshot.smtp));
patch(["server", "imap"], asRecord(snapshot.imap));
patch(["delivery", "imap_append_sent"], asRecord(snapshot.imap_append_sent));
}
mockSandboxSnapshot.current = null;
setSmtpTestResult(null);
setImapTestResult(null);
}
async function loadMockMailbox() {
setMailActionState("mock");
setMockError("");
try {
const response = await listMockMailboxMessages(settings);
setMockMessages(response.messages);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function openMockMessage(id: string) {
setMailActionState("mock");
setMockError("");
try {
const response = await getMockMailboxMessage(settings, id);
setSelectedMockMessage(response.message);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function clearMockMailbox() {
setMailActionState("mock");
setMockError("");
try {
await clearMockMailboxMessages(settings);
setMockMessages([]);
setSelectedMockMessage(null);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function failNextMockSmtp() {
setMailActionState("mock");
setMockError("");
try {
await updateMockMailboxFailures(settings, { fail_next_smtp: true });
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function failNextMockImap() {
setMailActionState("mock");
setMockError("");
try {
await updateMockMailboxFailures(settings, { fail_next_imap: true });
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Server settings</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<MailProfilePolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
campaignId={campaignId}
profiles={policyProfiles}
ownerUserId={data.campaign?.owner_user_id}
ownerGroupId={data.campaign?.owner_group_id}
canWrite={!locked}
locked={locked}
title="Campaign mail profile policy"
description="Campaign-level allow-list and wildcard limits applied after system, tenant and owner policies."
onSaved={refreshMailProfiles}
/>
<Card
title="Reusable mail profile"
actions={
<div className="button-row compact-actions">
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button>
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
</div>
}
>
<div className="form-grid compact responsive-form-grid">
<FormField label="Profile">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
<option value="">Inline SMTP/IMAP settings</option>
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
</select>
</FormField>
<FormField label="New profile name">
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} mail profile` : "Campaign mail profile"} />
</FormField>
</div>
{selectedProfile && (
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p>
)}
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
</Card>
<Card title="Mail server settings">
<div className="mail-server-settings-grid">
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>SMTP login</h3>
<ToggleSwitch label="Mock server settings" checked={mockSandboxEnabled} disabled={locked || usingMailProfile} onChange={toggleMockMailSandbox} />
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Host"><input value={getText(smtp, "host")} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(smtp, "port", 587)} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(smtp, "username")} disabled={smtpCredentialDisabled} onChange={(event) => patch(["server", "smtp", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(smtp, "password")} disabled={smtpCredentialDisabled} onChange={(event) => patch(["server", "smtp", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(smtp, "security", "starttls")} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Timeout seconds"><input type="number" value={getNumber(smtp, "timeout_seconds", 30)} disabled={smtpDisabled} onChange={(event) => patch(["server", "smtp", "timeout_seconds"], Number(event.target.value || 0))} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runSmtpTest} disabled={locked || mailActionState === "smtp"}>{mailActionState === "smtp" ? "Testing…" : (usingMailProfile ? "Test profile SMTP" : "Test SMTP login")}</Button>
</div>
<MailActionResult result={smtpTestResult} />
</section>
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>IMAP sent-folder append</h3>
</div>
<div className="form-grid compact responsive-form-grid">
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Enable IMAP" checked={imapEnabled} disabled={locked || usingMailProfile} onChange={toggleImap} />
</div>
<FormField label="Host"><input value={getText(imap, "host")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(imap, "port", 993)} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(imap, "username")} disabled={imapCredentialDisabled} onChange={(event) => patch(["server", "imap", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(imap, "password")} disabled={imapCredentialDisabled} onChange={(event) => patch(["server", "imap", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(imap, "security", "tls")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Detected/saved sent folder"><input value={getText(imap, "sent_folder", "auto")} disabled={imapServerDisabled} onChange={(event) => patch(["server", "imap", "sent_folder"], event.target.value)} /></FormField>
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Append successfully sent messages to Sent" checked={getBool(imapAppend, "enabled")} disabled={imapDisabled} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
<FormField label="Append folder"><input value={getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"))} disabled={imapDisabled || !getBool(imapAppend, "enabled")} onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runImapTest} disabled={imapDisabled || mailActionState === "imap"}>{mailActionState === "imap" ? "Testing…" : (usingMailProfile ? "Test profile IMAP" : "Test IMAP login")}</Button>
<Button variant="primary" onClick={runFolderLookup} disabled={imapDisabled || mailActionState === "folders"}>{mailActionState === "folders" ? "Looking up…" : "Folders…"}</Button>
</div>
<p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>
<MailActionResult result={imapTestResult} />
<FolderLookupResult result={folderResult} disabled={imapDisabled} onUseDetected={useDetectedSentFolder} />
</section>
</div>
</Card>
<Card title="Mock mail sandbox">
<div className="subsection-heading split">
<div>
<h3>Captured messages</h3>
<p className="muted small-note">Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={loadMockMailbox} disabled={mailActionState === "mock"}>{mailActionState === "mock" ? "Loading…" : "Reload mailbox"}</Button>
<Button onClick={failNextMockSmtp} disabled={mailActionState === "mock"}>Fail next SMTP</Button>
<Button onClick={failNextMockImap} disabled={mailActionState === "mock"}>Fail next IMAP</Button>
<Button variant="danger" onClick={clearMockMailbox} disabled={mailActionState === "mock" || mockMessages.length === 0}>Clear</Button>
</div>
</div>
{mockError && <DismissibleAlert tone="danger" resetKey={mockError} floating>{mockError}</DismissibleAlert>}
<DataGrid
id={`campaign-${campaignId}-server-mock-mailbox`}
rows={mockMessages}
columns={mockMailboxColumns(openMockMessage)}
getRowKey={(message) => message.id}
emptyText="No mock messages captured yet."
className="data-table compact-table"
/>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
{selectedMockMessage && (
<MessagePreviewOverlay
title="Captured mock mail"
subject={selectedMockMessage.subject || "Mock message"}
bodyMode="text"
text={selectedMockMessage.body_preview || ""}
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
metaItems={mockMessageMetaItems(selectedMockMessage)}
attachments={mockMessageAttachments(selectedMockMessage)}
raw={selectedMockMessage.raw_eml}
rawLabel="Raw MIME"
onClose={() => setSelectedMockMessage(null)}
/>
)}
</div>
);
}
function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "From", value: message.from_header || message.envelope_from || "—" },
{ label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
{ label: "Kind", value: message.kind || "—" },
{ label: "Folder", value: message.folder || "—" },
{ label: "Message-ID", value: message.message_id || "—" },
{ label: "Size", value: `${message.size_bytes || 0} bytes` }
];
}
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
return (message.attachments ?? []).map((attachment, index) => ({
filename: attachment.filename || `Attachment ${index + 1}`,
contentType: attachment.content_type || undefined,
sizeBytes: attachment.size_bytes ?? undefined
}));
}
function formatMockDate(value: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function mockMailboxColumns(openMockMessage: (id: string) => Promise<void>): DataGridColumn<MockMailboxMessage>[] {
return [
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: [{ value: "SMTP", label: "SMTP" }, { value: "IMAP", label: "IMAP" }] }, render: (message) => <span className="status-badge neutral">{message.kind === "imap_append" ? "IMAP" : "SMTP"}</span>, value: (message) => message.kind === "imap_append" ? "IMAP" : "SMTP" },
{ id: "received", header: "Received", width: 180, sortable: true, filterable: true, filterType: "date", value: (message) => formatMockDate(message.created_at), sortValue: (message) => message.created_at ?? "" },
{ id: "subject", header: "Subject", width: "minmax(240px, 1fr)", sortable: true, filterable: true, value: (message) => message.subject || "—" },
{ id: "envelope", header: "Envelope", width: 300, resizable: true, filterable: true, value: (message) => `${message.envelope_from || message.folder || "—"}${(message.envelope_recipients || []).join(", ") || message.folder || "—"}` },
{ id: "attachments", header: "Attachments", width: 130, sortable: true, filterable: true, filterType: "integer", value: (message) => message.attachment_count ?? 0 },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (message) => <Button onClick={() => openMockMessage(message.id)}>Open</Button> }
];
}
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<DismissibleAlert tone={result.ok ? "success" : "danger"} resetKey={`${result.ok}:${result.message}`} floating>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</DismissibleAlert>
);
}
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
if (!result) return null;
if (!result.ok) {
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
}
return (
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "—"}</strong></p>
{result.detected_sent_folder && <Button onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
{result.folders.length > 0 && (
<div className="field-chip-list">
{result.folders.slice(0, 12).map((folder) => (
<span className="field-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
))}
{result.folders.length > 12 && <span className="field-chip">+{result.folders.length - 12} more</span>}
</div>
)}
</DismissibleAlert>
);
}

View File

@@ -0,0 +1,387 @@
import { useMemo } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import EmailAddressInput from "../../components/email/EmailAddressInput";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool } from "./utils/draftEditor";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
type MailboxAddress
} from "../../utils/emailAddresses";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
const recipientHeaderRows = [
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
{ key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." },
{ key: "bcc", label: "BCC", toggleKey: "allow_individual_bcc", toggleLabel: "Allow individual BCC", addLabel: "Add BCC", emptyText: "No global BCC recipients configured." }
] as const;
type RecipientAddressKey = "to" | "cc" | "bcc";
type EntryAddressColumn = {
key: RecipientAddressKey | "from" | "reply_to";
label: string;
allowMultiple: boolean;
addLabel: string;
emptyText: string;
mergeKey?: "merge_to" | "merge_cc" | "merge_bcc" | "merge_reply_to";
};
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "recipients",
unsavedTitle: "Unsaved recipient changes",
unsavedMessage: "Recipient address data has unsaved changes. Save it before leaving, or discard it and continue."
});
const recipientsSection = asRecord(displayDraft.recipients);
const entries = asRecord(displayDraft.entries);
const entryDefaults = asRecord(entries.defaults);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc),
bcc: addressesFromValue(recipientsSection.bcc)
};
const entryAddressColumns = useMemo<EntryAddressColumn[]>(() => {
const columns: EntryAddressColumn[] = [];
if (getBool(recipientsSection, "allow_individual_from")) {
columns.push({ key: "from", label: "From", allowMultiple: false, addLabel: "Set From", emptyText: "Uses global From." });
}
if (getBool(recipientsSection, "allow_individual_reply_to")) {
columns.push({ key: "reply_to", label: "Reply-To", allowMultiple: true, addLabel: "Add Reply-To", emptyText: "Uses global Reply-To.", mergeKey: "merge_reply_to" });
}
if (getBool(recipientsSection, "allow_individual_to")) {
columns.push({ key: "to", label: "To", allowMultiple: true, addLabel: "Add To", emptyText: "No recipient address.", mergeKey: "merge_to" });
}
if (getBool(recipientsSection, "allow_individual_cc")) columns.push({ key: "cc", label: "CC", allowMultiple: true, addLabel: "Add CC", emptyText: "No CC.", mergeKey: "merge_cc" });
if (getBool(recipientsSection, "allow_individual_bcc")) columns.push({ key: "bcc", label: "BCC", allowMultiple: true, addLabel: "Add BCC", emptyText: "No BCC.", mergeKey: "merge_bcc" });
return columns;
}, [recipientsSection]);
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
}
function addRecipient(afterIndex = inlineEntries.length - 1) {
const nextIndex = inlineEntries.length + 1;
const newEntry = {
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
active: true,
to: [],
cc: [],
bcc: [],
name: "",
email: "",
from: [],
reply_to: [],
merge_to: false,
merge_cc: true,
merge_bcc: true,
merge_reply_to: true
};
replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry));
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
replaceInlineEntries(nextEntries);
}
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
updateEntry(index, (entry) => {
if (key === "from") {
return { ...entry, from: addresses.slice(0, 1) };
}
const nextEntry = { ...entry, [key]: addresses };
if (key === "to") {
const address = addresses[0] ?? { name: "", email: "" };
return {
...nextEntry,
name: address.name ?? "",
email: address.email
};
}
return nextEntry;
});
}
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) {
updateEntry(index, (entry) => {
const next = { ...entry, [mergeKey]: checked };
delete next[mergeKey.replace("merge_", "combine_")];
return next;
});
}
function removeEntry(index: number) {
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
}
function moveEntry(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Sender & Recipients</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Campaign sender">
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="Default From address">
<EmailAddressInput
value={defaultFrom}
suggestions={addressSuggestions}
allowMultiple={false}
disabled={locked}
addLabel="Set From"
emptyText="No global From address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "from"], addresses.slice(0, 1))}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual senders"
checked={getBool(recipientsSection, "allow_individual_from")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)}
/>
</div>
</div>
<div className="campaign-header-grid">
<FormField label="Global Reply-To address">
<EmailAddressInput
value={globalReplyTo}
suggestions={addressSuggestions}
allowMultiple
disabled={locked}
addLabel="Add Reply-To"
emptyText="No global Reply-To address configured."
onChange={(addresses: MailboxAddress[]) => patch(["recipients", "reply_to"], addresses)}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="Allow individual Reply-To"
checked={getBool(recipientsSection, "allow_individual_reply_to")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)}
/>
</div>
</div>
</div>
</Card>
<Card title="Global recipient headers">
<div className="campaign-header-stack">
{recipientHeaderRows.map((row) => (
<div className="campaign-header-grid" key={row.key}>
<FormField label={row.label}>
<EmailAddressInput
value={globalRecipientValues[row.key] ?? []}
suggestions={addressSuggestions}
allowMultiple
disabled={locked}
addLabel={row.addLabel}
emptyText={row.emptyText}
onChange={(addresses: MailboxAddress[]) => patch(["recipients", row.key], addresses)}
/>
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label={row.toggleLabel}
checked={getBool(recipientsSection, row.toggleKey)}
disabled={locked}
onChange={(checked) => patch(["recipients", row.toggleKey], checked)}
/>
</div>
</div>
))}
</div>
</Card>
<section className="recipient-profiles-section">
<div className="subsection-heading split">
<h3>Recipient profiles</h3>
<Button disabled>Import</Button>
</div>
{inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
)}
{!source.type && (
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries.slice(0, 100)}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient profiles are stored in the current version yet."
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="Add first recipient" />}
className="recipient-table-wrap recipient-address-table"
/>
</div>
)}
</section>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
type RecipientProfileColumnContext = {
locked: boolean;
entries: Record<string, unknown>[];
entryDefaults: Record<string, unknown>;
entryAddressColumns: EntryAddressColumn[];
addressSuggestions: MailboxAddress[];
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
addRecipient: (afterIndex?: number) => void;
moveEntry: (index: number, targetIndex: number) => void;
removeEntry: (index: number) => void;
};
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
id: column.key,
header: column.label,
width: column.key === "to" ? "minmax(260px, 1.2fr)" : 250,
resizable: true,
filterable: true,
render: (entry, index) => (
<div className="recipient-address-cell">
<EmailAddressInput
value={getEntryAddresses(entry, column.key)}
suggestions={addressSuggestions}
allowMultiple={column.allowMultiple}
compact
disabled={locked}
addLabel={column.addLabel}
emptyText={column.emptyText}
onChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
/>
{column.mergeKey && (
<div className="recipient-address-merge">
<ToggleSwitch
label="Merge"
checked={getEntryMerge(entry, entryDefaults, column.mergeKey)}
disabled={locked}
onChange={(checked) => updateEntryMerge(index, column.mergeKey!, checked)}
/>
<span className="muted">Merge with global {column.label}</span>
</div>
)}
</div>
),
value: (entry) => getEntryAddresses(entry, column.key).map((address) => `${address.name ?? ""} ${address.email ?? ""}`).join(", ")
})),
{ id: "active", header: "Active", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "Active" }, { value: "inactive", label: "Inactive" }] }, render: (entry, index) => <ToggleSwitch label="Active" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
{
id: "actions",
header: "Actions",
width: 180,
sticky: "end",
render: (_entry, index) => (
<DataGridRowActions
disabled={locked}
onAddBelow={() => addRecipient(index)}
onRemove={() => removeEntry(index)}
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
addLabel="Add recipient below"
removeLabel="Remove recipient"
moveUpLabel="Move recipient up"
moveDownLabel="Move recipient down"
/>
)
}
];
}
function getEntryMerge(entry: Record<string, unknown>, defaults: Record<string, unknown>, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>): boolean {
const legacyKey = mergeKey.replace("merge_", "combine_");
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
return true;
}
function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
if (key === "to") {
const explicit = addressesFromValue(entry.to);
return explicit.length ? explicit : fallbackRecipientAddress(entry);
}
if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
if (key === "reply_to") return addressesFromValue(entry.reply_to);
return addressesFromValue(entry[key]);
}
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return direct?.email ? [direct] : [];
}
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
if (!existing.has(preferred)) return preferred;
let counter = entries.length + 1;
let candidate = `recipient-${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `recipient-${counter}`;
}
return candidate;
}

View File

@@ -0,0 +1,203 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { addressesFromValue } from "../../utils/emailAddresses";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "recipient-data",
unsavedTitle: "Unsaved recipient data changes",
unsavedMessage: "Recipient field values or attachments have unsaved changes. Save them before leaving, or discard them and continue."
});
const entries = asRecord(displayDraft.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
replaceInlineEntries(nextEntries);
}
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
fields: {
...asRecord(entry.fields),
[field]: value
}
}));
}
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Recipient data</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Recipient field values and attachments">
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>}
{inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert>
)}
{inlineEntries.length > 0 && (
<DataGrid
id={`campaign-${campaignId}-recipient-data`}
rows={inlineEntries.slice(0, 100)}
columns={recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient data found."
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
/>
)}
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
type RecipientDataColumnContext = {
settings: ApiSettings;
campaignId: string;
locked: boolean;
fieldDefinitions: ReturnType<typeof getDraftFields>;
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
zipConfig: AttachmentZipCollection;
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
updateEntryField: (index: number, field: string, value: unknown) => void;
};
function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
{
id: "recipient",
header: "Recipient",
width: 260,
resizable: true,
sortable: true,
filterable: true,
sticky: "start",
render: (entry) => (
<Link className="recipient-data-identity" to="../recipients" title="Open recipient address profile">
<span className="recipient-data-address">{firstRecipientEmail(entry) || "No To address"}</span>
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
</Link>
),
value: firstRecipientEmail
},
{
id: "attachments",
header: "Attachments",
width: 180,
filterable: true,
render: (entry, index) => {
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<AttachmentRulesOverlay
title={`Attachments for recipient ${index + 1}`}
rules={attachments}
settings={settings}
campaignId={campaignId}
disabled={locked}
buttonLabel={`entries: ${attachments.length}`}
basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
);
},
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
},
...fieldDefinitions.map((field): DataGridColumn<Record<string, unknown>> => ({
id: `field-${field.name}`,
header: field.label || field.name,
width: 190,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
render: (entry, index) => {
const fields = asRecord(entry.fields);
return (
<FieldValueInput
className="recipient-field-input"
fieldType={field.type}
value={fields[field.name]}
disabled={locked || field.can_override === false}
placeholder={field.can_override === false ? "Uses global value" : undefined}
onChange={(value) => updateEntryField(index, field.name, value)}
/>
);
},
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
}))
];
}
function firstRecipientEmail(entry: Record<string, unknown>): string {
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
}
function extraRecipientCount(entry: Record<string, unknown>): number {
const count = addressesFromValue(entry.to).length;
return Math.max(0, count - 1);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,381 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import DismissibleAlert from "../../components/DismissibleAlert";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
type BodyMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [bodyMode, setBodyMode] = useState<BodyMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const [attachmentPreviewRules, setAttachmentPreviewRules] = useState<CampaignAttachmentPreviewRule[]>([]);
const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false);
const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "template",
unsavedTitle: "Unsaved template changes",
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${new Date(loadedVersion.autosaved_at).toLocaleString()}` : "Loaded",
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]);
const builtInAddressNames = useMemo(() => recipientAddressTemplateFieldOptions().map((field) => field.name), []);
const localAvailableNames = useMemo(() => new Set([...localFieldNames, ...builtInAddressNames]), [builtInAddressNames, localFieldNames]);
const globalAvailableNames = useMemo(() => new Set(globalFieldNames), [globalFieldNames]);
const allAvailableNames = useMemo(() => new Set([...localAvailableNames, ...globalAvailableNames]), [globalAvailableNames, localAvailableNames]);
const localFieldOptions = useMemo(() => {
const options = [...recipientAddressTemplateFieldOptions(), ...localFieldNames.map((name) => ({ name, label: name }))];
return options.filter((option, index) => options.findIndex((candidate) => candidate.name === option.name) === index);
}, [localFieldNames]);
const globalFieldOptions = useMemo(() => globalFieldNames.map((name) => ({ name, label: name })), [globalFieldNames]);
const entries = asRecord(displayDraft.entries);
const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
const activePreviewEntries = useMemo(
() => inlineEntries.map((entry, sourceIndex) => ({ entry, sourceIndex: sourceIndex + 1 })).filter(({ entry }) => entry.active !== false),
[inlineEntries]
);
const previewEntries = activePreviewEntries.length > 0 ? activePreviewEntries : [{ entry: {}, sourceIndex: 0 }];
const previewSelection = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? previewEntries[0];
const previewEntry = previewSelection.entry;
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo(
() => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames, { local: localAvailableNames, global: globalAvailableNames }),
[usedPlaceholders, allAvailableNames, globalAvailableNames, localAvailableNames]
);
const previewContext = useMemo(() => buildTemplatePreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
const selectedPreviewEntryIndex = previewSelection.sourceIndex;
const previewAttachmentRules = useMemo(
() => attachmentPreviewRules.filter((rule) => rule.entry_index === selectedPreviewEntryIndex),
[attachmentPreviewRules, selectedPreviewEntryIndex]
);
const previewAttachments = useMemo(
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError),
[attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules]
);
useEffect(() => {
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]);
useEffect(() => {
if (!previewOpen || !version?.id || !draft) return;
let cancelled = false;
setAttachmentPreviewLoading(true);
setAttachmentPreviewError("");
const handle = window.setTimeout(() => {
void previewCampaignAttachments(settings, campaignId, version.id, {
include_unmatched: false,
campaign_json: displayDraft
})
.then((response) => {
if (!cancelled) setAttachmentPreviewRules(response.rules);
})
.catch((reason: unknown) => {
if (!cancelled) {
setAttachmentPreviewRules([]);
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
}
})
.finally(() => {
if (!cancelled) setAttachmentPreviewLoading(false);
});
}, 120);
return () => {
cancelled = true;
window.clearTimeout(handle);
};
}, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]);
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
const token = `{{${namespace}:${name}}}`;
const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length;
const nextText = `${currentText.slice(0, start)}${token}${currentText.slice(end)}`;
patchTemplateText(target, nextText);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + token.length;
element?.setSelectionRange(cursor, cursor);
});
}
function addUndefinedField(field: UndefinedPlaceholder) {
if (!draft || locked || !field.name) return;
const existingFields = asArray(draft.fields).map(asRecord);
const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name);
if (!alreadyDefined) {
patch(["fields"], [
...existingFields,
{
name: field.name,
label: humanizeFieldName(field.name),
type: "string",
required: false,
can_override: true
}
]);
}
setUndefinedDialog(null);
}
function removePlaceholder(field: UndefinedPlaceholder) {
if (locked) return;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = removePlaceholderFromText(getText(nextTemplate, "subject"), field.raw);
nextTemplate.text = removePlaceholderFromText(getText(nextTemplate, "text"), field.raw);
nextTemplate.html = removePlaceholderFromText(getText(nextTemplate, "html"), field.raw);
next.template = nextTemplate;
return next;
});
markDirty();
setUndefinedDialog(null);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Template</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button disabled>Manage templates</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<div className="dashboard-grid template-editor-grid">
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
<div className="form-grid">
<FormField label="Subject">
<input
ref={subjectRef}
value={getText(template, "subject")}
disabled={locked}
onFocus={() => setActiveEditor("subject")}
onChange={(event) => patchTemplateText("subject", event.target.value)}
/>
</FormField>
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
</div>
{bodyMode === "text" && (
<FormField label="Plain text body">
<textarea
ref={textRef}
rows={16}
value={getText(template, "text")}
disabled={locked}
onFocus={() => setActiveEditor("text")}
onChange={(event) => patchTemplateText("text", event.target.value)}
/>
</FormField>
)}
{bodyMode === "html" && (
<FormField label="HTML body">
<textarea
ref={htmlRef}
rows={16}
value={getText(template, "html")}
disabled={locked}
onFocus={() => setActiveEditor("html")}
onChange={(event) => patchTemplateText("html", event.target.value)}
/>
</FormField>
)}
<div className="button-row template-editor-actions">
<Button disabled>Load from library</Button>
<Button disabled>Save to library</Button>
</div>
</div>
</Card>
<div className="template-side-stack">
<Card title="Fields">
{invalidNamespacePlaceholders.length > 0 && (
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
)}
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
<h3 className="section-mini-heading">Global fields</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFieldOptions}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Local fields</h3>
<p className="muted small-note">Address fields use the effective merged or overridden headers. Use <code>to[2]</code> or <code>to[2].email</code> for a specific additional address.</p>
<TemplateFieldChipList
namespace="local"
fields={localFieldOptions}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Used in template, but undefined</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card>
</div>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</>
</LoadingFrame>
{previewOpen && (
<MessagePreviewOverlay
title="Template preview"
bodyMode={bodyMode}
subject={previewSubject}
text={previewText}
html={previewHtml}
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."}
attachments={previewAttachments}
navigation={{
index: Math.min(previewIndex, previewEntries.length - 1),
total: previewEntries.length,
onFirst: () => setPreviewIndex(0),
onPrevious: () => setPreviewIndex((value) => Math.max(0, value - 1)),
onNext: () => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1)),
onLast: () => setPreviewIndex(previewEntries.length - 1)
}}
onClose={() => setPreviewOpen(false)}
/>
)}
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="template"
removeLabel="Remove from template"
onCancel={() => setUndefinedDialog(null)}
onRemove={removePlaceholder}
onAddField={addUndefinedField}
/>
</div>
);
}
function mapResolvedAttachmentsToPreviewBoxes(
rules: CampaignAttachmentPreviewRule[],
loading: boolean,
error: string
): MessagePreviewAttachment[] {
if (loading) {
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
}
if (error) {
return [{ filename: "Attachment preview unavailable", detail: error }];
}
return rules.flatMap((rule) => {
const detailParts = [
rule.source === "global" ? "Global" : "Recipient",
rule.label,
rule.required ? "required" : "optional",
rule.pattern
].filter(Boolean);
const detail = detailParts.join(" · ");
if (rule.matches.length > 0) {
return rule.matches.map((match) => ({
filename: match.filename || match.display_path,
label: rule.label,
detail: `${detail}${match.display_path ? ` · ${match.display_path}` : ""}`,
contentType: match.content_type,
sizeBytes: match.size_bytes,
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
}));
}
return [{
filename: `No file matched ${rule.pattern || "attachment pattern"}`,
label: rule.label,
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`,
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
}];
});
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim();
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return `Recipient ${index + 1}`;
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
}

View File

@@ -0,0 +1,379 @@
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import ToggleSwitch from "../../../components/ToggleSwitch";
import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
import { insertAfter, moveArrayItem } from "../../../utils/arrayOrder";
import { asRecord } from "../utils/campaignView";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
type AttachmentRulesOverlayProps = {
title: string;
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
buttonLabel?: string;
emptyText?: string;
basePaths?: AttachmentBasePath[];
zipConfig?: AttachmentZipCollection;
onChange: (rules: AttachmentRule[]) => void;
};
type AttachmentRulesTableProps = {
rules: AttachmentRule[];
settings: ApiSettings;
campaignId: string;
disabled?: boolean;
emptyText?: string;
basePaths?: AttachmentBasePath[];
id?: string;
showAddButton?: boolean;
activeChooserRuleIndex?: number | null;
zipConfig?: AttachmentZipCollection;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
type FileChooserState = {
ruleIndex: number;
basePath: AttachmentBasePath | null;
};
export default function AttachmentRulesOverlay({
title,
rules,
settings,
campaignId,
disabled = false,
buttonLabel,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
zipConfig = { enabled: false, archives: [] },
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
const [draftRules, setDraftRules] = useState<AttachmentRule[]>([]);
const summary = useMemo(() => summarizeAttachmentRules(rules), [rules]);
const label = buttonLabel ?? `direct: ${summary.direct} / rules: ${summary.rules}`;
function openOverlay() {
setDraftRules(cloneAttachmentRules(rules));
setOpen(true);
}
function cancelOverlay() {
setOpen(false);
}
function saveOverlay() {
if (disabled) return;
onChange(draftRules);
setOpen(false);
}
const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="attachment-rules-title">{title}</h2>
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<AttachmentRulesDataGrid
rules={draftRules}
settings={settings}
campaignId={campaignId}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
zipConfig={zipConfig}
activeChooserRuleIndex={null}
onChange={setDraftRules}
/>
</div>
<footer className="modal-footer">
<Button onClick={cancelOverlay}>Cancel</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
</footer>
</div>
</div>,
document.body
) : null;
return (
<>
<Button className="attachment-summary-button" onClick={openOverlay} disabled={disabled && rules.length === 0} title={`${summary.direct} direct file(s), ${summary.rules} rule(s) / pattern(s)`}>
{label}
</Button>
{dialog}
</>
);
}
function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
return rules.map((rule) => ({
...rule,
...(rule.zip && typeof rule.zip === "object" ? { zip: { ...asRecord(rule.zip) } } : {})
}));
}
export function AttachmentRulesTable({
showAddButton = true,
onChange,
...tableProps
}: AttachmentRulesTableProps) {
function addRule() {
onChange([
...tableProps.rules,
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules), tableProps.basePaths?.[0]?.id ?? "")
]);
}
return (
<div className="attachment-rules-editor">
<div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
{showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions">
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled || (tableProps.basePaths?.length ?? 0) === 0}>Add file</Button>
</div>
)}
</div>
</div>
);
}
export function AttachmentRulesDataGrid({
rules,
settings,
campaignId,
disabled = false,
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
id = "attachment-rules",
activeChooserRuleIndex = null,
zipConfig = { enabled: false, archives: [] },
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
function patchRule(index: number, patch: Partial<AttachmentRule>) {
onChange(rules.map((rule, currentIndex) => currentIndex === index ? { ...rule, ...patch } : rule));
}
function addRule(afterIndex = rules.length - 1) {
if (disabled || basePaths.length === 0) return;
const nextRule = createAttachmentRule(basePaths[0].path, nextAttachmentLabel(rules), basePaths[0].id);
onChange(insertAfter(rules, afterIndex, nextRule));
}
function removeRule(index: number) {
setFileChooser(null);
onChange(rules.filter((_, currentIndex) => currentIndex !== index));
}
function moveRule(index: number, targetIndex: number) {
if (disabled || index === targetIndex) return;
setFileChooser(null);
onChange(moveArrayItem(rules, index, targetIndex));
}
function openFileChooser(ruleIndex: number) {
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
}
const rule = rules[ruleIndex] ?? {};
const currentPath = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentPath);
const basePath = basePaths.find((item) => item.id === currentBasePathId)
?? basePaths.find((item) => item.path === currentPath)
?? (!explicitlyReferenced ? basePaths[0] : undefined)
?? null;
if (!basePath) return;
setFileChooser({ ruleIndex, basePath });
}
function selectAttachment(selection: ManagedAttachmentSelection) {
if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, {
base_path_id: fileChooser.basePath?.id ?? "",
base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."),
file_filter: selection.fileFilter,
type: selection.selectionType === "file" ? "direct" : "pattern",
include_subdirs: false,
label: getText(currentRule, "label") || `Attachment ${fileChooser.ruleIndex + 1}`
});
setFileChooser(null);
}
return (
<>
<DataGrid
id={id}
rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && (
<ManagedFileChooser
open
settings={settings}
campaignId={campaignId}
mode="attachment"
source={fileChooser.basePath?.source}
basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment}
/>
)}
</>
);
}
type AttachmentRuleColumnContext = {
disabled: boolean;
rules: AttachmentRule[];
basePaths: AttachmentBasePath[];
zipConfig: AttachmentZipCollection;
activeChooserRuleIndex: number | null;
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
addRule: (afterIndex?: number) => void;
moveRule: (index: number, targetIndex: number) => void;
openFileChooser: (ruleIndex: number) => void;
removeRule: (index: number) => void;
};
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{
id: "base_path",
header: "Base path",
width: 250,
sortable: true,
filterable: true,
columnType: "from-list",
list: { options: basePaths.map((basePath) => ({ value: basePath.path, label: basePath.name || basePath.path })) },
render: (rule, index) => {
const currentBasePathValue = getText(rule, "base_dir");
const currentBasePathId = getText(rule, "base_path_id");
const explicitlyReferenced = Boolean(currentBasePathId || currentBasePathValue);
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId)
?? basePaths.find((basePath) => basePath.path === currentBasePathValue)
?? (!explicitlyReferenced ? basePaths[0] : undefined);
return (
<select
value={selectedBasePath?.id ?? ""}
disabled={disabled || basePaths.length === 0}
onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}}
>
{!selectedBasePath && (
<option value="" disabled>{basePaths.length === 0 ? "No individual attachment source" : "Unavailable attachment source"}</option>
)}
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select>
);
},
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")
},
{
id: "file_filter",
header: "File / pattern",
width: "minmax(260px, 1fr)",
resizable: true,
sortable: true,
filterable: true,
render: (rule, index) => (
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled || basePaths.length === 0}
readOnly
tabIndex={-1}
placeholder="Choose a managed file or pattern"
onClick={() => !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (!disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}}
/>
<Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>
</div>
),
value: (rule) => getText(rule, "file_filter")
},
...(zipConfig.enabled ? [{
id: "zip",
header: "ZIP archive",
width: 240,
sortable: true,
filterable: true,
columnType: "from-list",
list: {
options: [
{ value: "exclude", label: "Exclude from ZIP" },
{ value: "inherit", label: "Campaign standard" },
...zipConfig.archives.map((archive) => ({ value: archive.id, label: archive.name }))
]
},
render: (rule: AttachmentRule, index: number) => {
const selection = attachmentRuleZipSelection(rule);
return (
<select
value={selection}
disabled={disabled || zipConfig.archives.length === 0}
aria-label={`ZIP archive for ${getText(rule, "label") || `attachment ${index + 1}`}`}
onChange={(event) => patchRule(index, { zip: { ...asRecord(rule.zip), archive_id: event.target.value } })}
>
<option value="exclude">Exclude from ZIP</option>
<option value="inherit">Campaign standard</option>
{zipConfig.archives.map((archive) => <option key={archive.id} value={archive.id}>{archive.name}</option>)}
</select>
);
},
value: (rule: AttachmentRule) => attachmentRuleZipSelection(rule)
} satisfies DataGridColumn<AttachmentRule>] : []),
{ id: "required", header: "Required", width: 175, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (rule, index) => <ToggleSwitch label="Required" checked={getBool(rule, "required", true)} disabled={disabled} onChange={(checked) => patchRule(index, { required: checked })} />, value: (rule) => getBool(rule, "required", true) ? "required" : "optional" },
{
id: "actions",
header: "Actions",
width: 180,
sticky: "end",
render: (_rule, index) => (
<DataGridRowActions
disabled={disabled}
onAddBelow={() => addRule(index)}
onRemove={() => removeRule(index)}
onMoveUp={index > 0 ? () => moveRule(index, index - 1) : undefined}
onMoveDown={index < rules.length - 1 ? () => moveRule(index, index + 1) : undefined}
addLabel="Add attachment below"
removeLabel="Remove attachment"
moveUpLabel="Move attachment up"
moveDownLabel="Move attachment down"
/>
)
}
];
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, CampaignListItem } from "../../../types";
import {
getCampaignShares,
getCampaignShareTargets,
revokeCampaignShare,
updateCampaignOwner,
upsertCampaignShare,
type CampaignShare,
type CampaignShareTargets
} from "../../../api/campaigns";
import Button from "../../../components/Button";
import Card from "../../../components/Card";
import ConfirmDialog from "../../../components/ConfirmDialog";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import Dialog from "../../../components/Dialog";
import FormField from "../../../components/FormField";
import StatusBadge from "../../../components/StatusBadge";
type TargetType = "user" | "group";
export default function CampaignAccessCard({
settings,
campaign,
onChanged,
onError
}: {
settings: ApiSettings;
campaign: CampaignListItem;
onChanged: () => Promise<void>;
onError: (message: string) => void;
}) {
const [available, setAvailable] = useState<boolean | null>(null);
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
const [shares, setShares] = useState<CampaignShare[]>([]);
const [ownerOpen, setOwnerOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<CampaignShare | null>(null);
const [ownerType, setOwnerType] = useState<TargetType>(campaign.owner_group_id ? "group" : "user");
const [ownerId, setOwnerId] = useState(campaign.owner_group_id || campaign.owner_user_id || "");
const [shareType, setShareType] = useState<TargetType>("user");
const [shareTargetId, setShareTargetId] = useState("");
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
const [busy, setBusy] = useState(false);
async function load() {
try {
const [nextTargets, nextShares] = await Promise.all([
getCampaignShareTargets(settings, campaign.id),
getCampaignShares(settings, campaign.id)
]);
setTargets(nextTargets);
setShares(nextShares.filter((item) => !item.revoked_at));
setAvailable(true);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.startsWith("403 ")) setAvailable(false);
else onError(message);
}
}
useEffect(() => {
setOwnerType(campaign.owner_group_id ? "group" : "user");
setOwnerId(campaign.owner_group_id || campaign.owner_user_id || "");
void load();
}, [campaign.id, campaign.owner_group_id, campaign.owner_user_id, settings.accessToken, settings.apiBaseUrl]);
const targetOptions = ownerType === "user" ? targets.users : targets.groups;
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
const targetMap = useMemo(() => new Map([
...targets.users.map((item) => [`user:${item.id}`, item] as const),
...targets.groups.map((item) => [`group:${item.id}`, item] as const)
]), [targets]);
const ownerLabel = campaign.owner_group_id
? targetMap.get(`group:${campaign.owner_group_id}`)?.name || "Group owner"
: targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "User owner";
async function saveOwner() {
if (!ownerId) return;
setBusy(true);
try {
await updateCampaignOwner(settings, campaign.id, ownerType === "user"
? { owner_user_id: ownerId, owner_group_id: null }
: { owner_user_id: null, owner_group_id: ownerId });
setOwnerOpen(false);
await onChanged();
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
async function saveShare() {
if (!shareTargetId) return;
setBusy(true);
try {
await upsertCampaignShare(settings, campaign.id, { target_type: shareType, target_id: shareTargetId, permission: sharePermission });
setShareOpen(false);
setShareTargetId("");
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
async function revoke() {
if (!revokeTarget) return;
setBusy(true);
try {
await revokeCampaignShare(settings, campaign.id, revokeTarget.id);
setRevokeTarget(null);
await load();
} catch (err) { onError(err instanceof Error ? err.message : String(err)); }
finally { setBusy(false); }
}
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
{
id: "target",
header: "Shared with",
width: "minmax(220px, 1fr)",
minWidth: 190,
resizable: true,
sticky: "start",
sortable: true,
filterable: true,
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
render: (row) => {
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? ` · ${target.secondary}` : ""}</div></div>;
}
},
{ id: "permission", header: "Access", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "Can edit" : "Can view"} /> },
{ id: "actions", header: "Actions", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>Remove</Button> }
], [targetMap]);
if (available === false) return null;
return (
<>
<Card title="Ownership and sharing" actions={<div className="button-row compact-actions"><Button onClick={() => setOwnerOpen(true)} disabled={available !== true}>Change owner</Button><Button onClick={() => setShareOpen(true)} disabled={available !== true}>Share</Button></div>}>
<p><strong>Owner:</strong> {ownerLabel}</p>
<p className="muted small-note">Permissions define what a role may do; ownership and explicit shares determine which campaigns that permission applies to.</p>
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "Loading campaign access…" : "This campaign has no explicit shares."} />
</Card>
<Dialog open={ownerOpen} title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
</Dialog>
<Dialog open={shareOpen} title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField>
</Dialog>
<ConfirmDialog open={Boolean(revokeTarget)} title="Remove campaign share" message="Remove this user's or group's explicit access to the campaign? Ownership and other group shares still apply." confirmLabel="Remove share" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
</>
);
}

View File

@@ -0,0 +1,36 @@
import { inputValueToFieldValue, normalizeFieldType, valueToInputText } from "../utils/fieldDefinitions";
export type FieldValueInputProps = {
fieldType?: string;
value: unknown;
disabled?: boolean;
placeholder?: string;
className?: string;
onChange: (value: unknown) => void;
};
export default function FieldValueInput({ fieldType = "string", value, disabled = false, placeholder, className, onChange }: FieldValueInputProps) {
const normalizedType = normalizeFieldType(fieldType);
const inputType = inputTypeForField(normalizedType);
const step = normalizedType === "integer" ? "1" : normalizedType === "double" ? "any" : undefined;
return (
<input
className={className}
type={inputType}
step={step}
value={valueToInputText(value, normalizedType)}
disabled={disabled}
placeholder={placeholder}
autoComplete={normalizedType === "password" ? "new-password" : undefined}
onChange={(event) => onChange(inputValueToFieldValue(normalizedType, event.target.value))}
/>
);
}
function inputTypeForField(fieldType: string): string {
if (fieldType === "integer" || fieldType === "double") return "number";
if (fieldType === "date") return "date";
if (fieldType === "password") return "password";
return "text";
}

View File

@@ -0,0 +1,235 @@
import { useState } from "react";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import {
forkCampaignVersion,
lockCampaignVersionPermanently,
unlockCampaignVersionUserLock,
unlockCampaignVersionValidation,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../../api/campaigns";
import {
canUnlockValidationVersion,
formatDateTime,
isFinalLockedVersion,
isHistoricalCampaignVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
} from "../utils/campaignView";
type LockedVersionNoticeProps = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | CampaignVersionListItem | null;
currentVersionId?: string | null;
reload: () => Promise<void>;
message?: string;
};
type ConfirmAction = "unlock-validation" | "unlock-user" | "permanent" | null;
export default function LockedVersionNotice({ settings, campaignId, version, currentVersionId, reload, message }: LockedVersionNoticeProps) {
const [busy, setBusy] = useState(false);
const [localError, setLocalError] = useState("");
const [localMessage, setLocalMessage] = useState("");
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null);
const historicalVersion = isHistoricalCampaignVersion(version, currentVersionId);
const validationLock = !historicalVersion && canUnlockValidationVersion(version);
const temporaryUserLock = !historicalVersion && isTemporaryUserLockedVersion(version);
const permanentUserLock = isPermanentUserLockedVersion(version);
const finalLock = isFinalLockedVersion(version);
const canCreateEditableCopy = !historicalVersion && (permanentUserLock || finalLock);
const presentation = lockPresentation(version, {
historicalVersion,
validationLock,
temporaryUserLock,
permanentUserLock,
finalLock,
});
async function runAction(action: Exclude<ConfirmAction, null>) {
if (!version || busy) return;
setBusy(true);
setLocalError("");
setLocalMessage("");
try {
if (action === "unlock-validation") {
await unlockCampaignVersionValidation(settings, campaignId, version.id);
setLocalMessage("Validation lock removed. Validation, build and generated queue state were invalidated.");
} else if (action === "unlock-user") {
await unlockCampaignVersionUserLock(settings, campaignId, version.id);
setLocalMessage("Temporary user lock removed. The version is editable again.");
} else {
await lockCampaignVersionPermanently(settings, campaignId, version.id);
setLocalMessage("Version permanently locked. Future changes require an editable copy.");
}
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function createEditableCopy() {
if (!version || busy) return;
setBusy(true);
setLocalError("");
setLocalMessage("");
try {
const result = await forkCampaignVersion(settings, campaignId, version.id, {
current_flow: "manual",
current_step: version.current_step ?? null,
});
setLocalMessage(`Created editable version #${result.version.version_number}.`);
await reload();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<DismissibleAlert tone="info" dismissible={false} className={`locked-version-notice lock-kind-${presentation.kind}`}>
<div className="locked-version-copy">
<strong>{presentation.title}</strong>{" "}
<span>{presentation.description}</span>
{message && <span className="locked-version-context"> {message}</span>}
{presentation.info && <span className="locked-version-reason"> {presentation.info}</span>}
{localMessage && <span className="locked-version-feedback"> {localMessage}</span>}
{localError && <span className="locked-version-error"> {localError}</span>}
</div>
<div className="button-row compact-actions locked-version-actions">
{validationLock && (
<Button onClick={() => setConfirmAction("unlock-validation")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock validation"}
</Button>
)}
{temporaryUserLock && (
<>
<Button onClick={() => setConfirmAction("unlock-user")} disabled={!version || busy}>
{busy ? "Working…" : "Unlock"}
</Button>
<Button variant="danger" onClick={() => setConfirmAction("permanent")} disabled={!version || busy}>
Lock permanently
</Button>
</>
)}
{canCreateEditableCopy && (
<Button variant="primary" onClick={() => void createEditableCopy()} disabled={!version || busy}>
{busy ? "Creating copy…" : "Create editable copy"}
</Button>
)}
</div>
<ConfirmDialog
open={confirmAction !== null}
title={confirmDialogTitle(confirmAction)}
message={confirmDialogMessage(confirmAction)}
confirmLabel={confirmDialogLabel(confirmAction)}
tone={confirmAction === "unlock-user" ? "default" : "danger"}
busy={busy}
onCancel={() => setConfirmAction(null)}
onConfirm={() => {
const action = confirmAction;
setConfirmAction(null);
if (action) void runAction(action);
}}
/>
</DismissibleAlert>
);
}
type LockFlags = {
historicalVersion: boolean;
validationLock: boolean;
temporaryUserLock: boolean;
permanentUserLock: boolean;
finalLock: boolean;
};
function lockPresentation(
version: CampaignVersionDetail | CampaignVersionListItem | null,
flags: LockFlags,
): { kind: string; title: string; description: string; info: string } {
if (flags.historicalVersion) {
return {
kind: "historical",
title: "Historical version.",
description: "This version is review-only. Only the campaign's current working version may be edited in place.",
info: `Version #${version?.version_number ?? "—"}.`,
};
}
if (flags.temporaryUserLock) {
return {
kind: "temporary-user",
title: "Temporarily locked version.",
description: "Campaign data is read-only, but an authorized user may unlock it or make the lock permanent.",
info: version?.user_locked_at ? `Locked ${formatDateTime(version.user_locked_at)}.` : "",
};
}
if (flags.permanentUserLock) {
return {
kind: "permanent-user",
title: "Permanently locked version.",
description: "This user-requested snapshot cannot be unlocked. Create an editable copy for future changes.",
info: version?.user_locked_at || version?.published_at
? `Locked ${formatDateTime(version.user_locked_at ?? version.published_at)}.`
: "",
};
}
if (flags.validationLock) {
return {
kind: "validation",
title: "Temporarily locked after validation.",
description: "This protects the validated build input. Unlocking invalidates validation, build and generated queue state.",
info: version?.locked_at ? `Validated ${formatDateTime(version.locked_at)}.` : "",
};
}
if (flags.finalLock) {
return {
kind: "delivery",
title: "Permanently locked delivery snapshot.",
description: "Delivery has started or the version is in a final state, so it cannot be unlocked in place.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
};
}
return {
kind: "other",
title: "Locked version.",
description: "This version is read-only. Create an editable copy to continue working.",
info: version?.locked_at ? `Locked ${formatDateTime(version.locked_at)}.` : "",
};
}
function confirmDialogTitle(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation?";
if (action === "unlock-user") return "Unlock temporary lock?";
if (action === "permanent") return "Lock permanently?";
return "Confirm action";
}
function confirmDialogMessage(action: ConfirmAction): string {
if (action === "unlock-validation") {
return "This makes the version editable and clears validation, build results, review acknowledgement and generated jobs for this version.";
}
if (action === "unlock-user") {
return "This removes the reversible user lock. Campaign data and existing workflow results are not otherwise changed.";
}
if (action === "permanent") {
return "This lock cannot be removed by any role. The version remains available for review and audit, but future changes require an editable copy.";
}
return "Continue?";
}
function confirmDialogLabel(action: ConfirmAction): string {
if (action === "unlock-validation") return "Unlock validation";
if (action === "unlock-user") return "Unlock";
if (action === "permanent") return "Lock permanently";
return "Confirm";
}

View File

@@ -0,0 +1,635 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import type { ApiSettings } from "../../../types";
import {
listFileSpaces,
listFiles,
listFolders,
resolveFilePatterns,
shareFileWithCampaign,
type FileFolder,
type FileSpace,
type ManagedFile
} from "../../../api/files";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import { FolderTree } from "../../files/components/FileManagerComponents";
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
import {
buildExplorerEntries,
buildFolderTree,
formatBytes,
formatDate,
normalizeFilePath,
normalizeFolder,
parentFolderPath,
sortExplorerEntries
} from "../../files/utils/fileManagerUtils";
import {
encodeManagedAttachmentSource,
parseManagedAttachmentSource,
type ManagedAttachmentSource
} from "../utils/attachments";
type ManagedFileChooserMode = "folder" | "attachment";
type AttachmentChoiceMode = "file" | "pattern";
type RememberedChooserState = {
spaceId?: string;
folder?: string;
pattern?: string;
};
export type ManagedFolderSelection = {
space: FileSpace;
folderPath: string;
source: string;
};
export type ManagedAttachmentSelection = ManagedFolderSelection & {
fileFilter: string;
matchCount: number;
fileIds: string[];
selectionType: AttachmentChoiceMode;
};
type ManagedFileChooserProps = {
open: boolean;
settings: ApiSettings;
campaignId: string;
mode: ManagedFileChooserMode;
source?: string;
basePath?: string;
initialPattern?: string;
rememberKey?: string;
onClose: () => void;
onSelectFolder?: (selection: ManagedFolderSelection) => void;
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
};
export default function ManagedFileChooser({
open,
settings,
campaignId,
mode,
source,
basePath = ".",
initialPattern = "",
rememberKey = mode,
onClose,
onSelectFolder,
onSelectAttachment
}: ManagedFileChooserProps) {
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
const normalizedBasePath = normalizeManagedBasePath(basePath);
const storageKey = useMemo(
() => `multimailer.managedFileChooser.${campaignId}.${rememberKey}`,
[campaignId, rememberKey]
);
const remembered = useMemo(() => readRememberedState(storageKey), [storageKey, open]);
const [spaces, setSpaces] = useState<FileSpace[]>([]);
const [selectedSpaceId, setSelectedSpaceId] = useState("");
const [files, setFiles] = useState<ManagedFile[]>([]);
const [folders, setFolders] = useState<FileFolder[]>([]);
const [currentFolder, setCurrentFolder] = useState("");
const [pattern, setPattern] = useState("");
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
const [loading, setLoading] = useState(false);
const [resolving, setResolving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const selectedSpace = spaces.find((item) => item.id === selectedSpaceId) ?? null;
const sourceLocked = mode === "attachment" && Boolean(parsedSource);
const sourceMatchesSpace = selectedSpace
? !parsedSource || (selectedSpace.owner_type === parsedSource.ownerType && selectedSpace.owner_id === parsedSource.ownerId)
: false;
const effectiveRoot = mode === "attachment" ? normalizedBasePath : "";
const entries = useMemo(
() => buildExplorerEntries(files, folders, currentFolder, false),
[currentFolder, files, folders]
);
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
const sortedEntries = useMemo(
() => sortExplorerEntries(entries, sortColumn, sortDirection),
[entries, sortColumn, sortDirection]
);
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
activeSpaceId: selectedSpaceId,
currentFolder,
onOpenFolder: (_spaceId, path) => openFolder(path)
});
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
setPattern(remembered.pattern ?? initialPattern.trim());
setPatternMatches(null);
setPendingExactFile(null);
void listFileSpaces(settings)
.then((response) => {
if (cancelled) return;
setSpaces(response.spaces);
const sourceSpace = response.spaces.find((item) => sourceMatches(item, parsedSource));
const rememberedSpace = response.spaces.find((item) => item.id === remembered.spaceId);
const firstSpace = sourceSpace ?? rememberedSpace ?? response.spaces[0] ?? null;
setSelectedSpaceId(firstSpace?.id ?? "");
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [initialPattern, open, parsedSource?.ownerId, parsedSource?.ownerType, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpace) return;
let cancelled = false;
setLoading(true);
setError("");
setPatternMatches(null);
const rememberedFolder = selectedSpace.id === remembered.spaceId ? normalizeFolder(remembered.folder || "") : "";
const initialFolder = rememberedFolder && isWithinRoot(rememberedFolder, effectiveRoot) ? rememberedFolder : effectiveRoot;
setCurrentFolder(initialFolder || effectiveRoot);
void Promise.all([
listFiles(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id }),
listFolders(settings, { owner_type: selectedSpace.owner_type, owner_id: selectedSpace.owner_id })
])
.then(([fileResponse, folderResponse]) => {
if (cancelled) return;
setFiles(fileResponse.files);
setFolders(folderResponse.folders);
})
.catch((reason: unknown) => {
if (!cancelled) setError(errorMessage(reason));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpaceId) return;
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern });
}, [currentFolder, open, pattern, selectedSpaceId, storageKey]);
function toggleSort(column: SortColumn) {
if (sortColumn === column) {
setSortDirection((current) => current === "asc" ? "desc" : "asc");
return;
}
setSortColumn(column);
setSortDirection(column === "name" ? "asc" : "desc");
}
function openFolder(path: string) {
const normalized = normalizeFolder(path);
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
setCurrentFolder(normalized);
setPatternMatches(null);
}
async function previewPattern(): Promise<ManagedFile[]> {
if (!selectedSpace) return [];
const trimmed = pattern.trim();
if (!trimmed) {
setPatternMatches([]);
return [];
}
setResolving(true);
setError("");
try {
const response = await resolveFilePatterns(settings, {
patterns: [trimmed],
owner_type: selectedSpace.owner_type,
owner_id: selectedSpace.owner_id,
path_prefix: effectiveRoot || undefined,
include_unmatched: false
});
const matches = response.patterns[0]?.matches ?? [];
setPatternMatches(matches);
return matches;
} catch (reason) {
setError(errorMessage(reason));
return [];
} finally {
setResolving(false);
}
}
function requestExactFile(file: ManagedFile) {
const exactPattern = relativePath(file.display_path, effectiveRoot);
const currentPattern = pattern.trim();
if (currentPattern && currentPattern !== exactPattern) {
setPendingExactFile(file);
return;
}
applyExactFile(file);
}
function applyExactFile(file: ManagedFile) {
setPattern(relativePath(file.display_path, effectiveRoot));
setPatternMatches([file]);
setPendingExactFile(null);
}
async function shareMatches(matches: ManagedFile[]) {
const toShare = matches.filter((file) => !file.shares?.some((share) => (
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
)));
await Promise.all(toShare.map((file) => shareFileWithCampaign(settings, file.id, campaignId)));
}
async function confirmSelection() {
if (!selectedSpace || !sourceMatchesSpace) return;
setSubmitting(true);
setError("");
try {
if (mode === "folder") {
onSelectFolder?.({
space: selectedSpace,
folderPath: currentFolder,
source: encodeManagedAttachmentSource(selectedSpace)
});
return;
}
const matches = patternMatches ?? await previewPattern();
await shareMatches(matches);
const trimmedPattern = pattern.trim();
onSelectAttachment?.({
space: selectedSpace,
folderPath: effectiveRoot,
source: encodeManagedAttachmentSource(selectedSpace),
fileFilter: trimmedPattern,
matchCount: matches.length,
fileIds: matches.map((file) => file.id),
selectionType: isExactPattern(trimmedPattern) ? "file" : "pattern"
});
} catch (reason) {
setError(errorMessage(reason));
} finally {
setSubmitting(false);
}
}
if (!open || typeof document === "undefined") return null;
const dialog = (
<>
<Dialog
open
title={mode === "folder" ? "Choose managed attachment source" : "Choose managed file or pattern"}
onClose={onClose}
closeDisabled={submitting}
backdropClassName="managed-file-chooser-backdrop"
className="managed-file-chooser-dialog"
bodyClassName="managed-file-chooser-body"
footerClassName="managed-file-chooser-footer"
footer={(
<>
<Button onClick={onClose} disabled={submitting}>Cancel</Button>
<Button
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
>
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
</Button>
</>
)}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{mode === "attachment" && !parsedSource && (
<DismissibleAlert tone="warning" dismissible={false}>
This attachment base path is not connected to a managed file space. Choose its folder under <strong>Attachments Attachment sources</strong> first.
</DismissibleAlert>
)}
{mode === "attachment" && parsedSource && !sourceMatchesSpace && !loading && (
<DismissibleAlert tone="danger" dismissible={false}>The configured managed file space is no longer available to this user.</DismissibleAlert>
)}
<div className="managed-file-chooser-layout" aria-busy={loading}>
<aside className="managed-file-chooser-spaces file-tree-panel" aria-label="File spaces and folders">
<div className="file-tree-heading">Spaces</div>
<div className="file-tree-list">
{spaces.map((space) => {
const selected = space.id === selectedSpaceId;
const lockedOut = sourceLocked && !sourceMatches(space, parsedSource);
return (
<div className="file-tree-space" key={space.id}>
<button
type="button"
className={`file-tree-node file-tree-root ${selected && currentFolder === effectiveRoot ? "is-active" : ""}`}
onClick={() => {
if (!selected) setSelectedSpaceId(space.id);
else openFolder(effectiveRoot);
}}
disabled={loading || lockedOut}
>
{space.owner_type === "group" ? <FolderOpen size={15} aria-hidden="true" /> : <Home size={15} aria-hidden="true" />}
<span>{space.label}</span>
</button>
{selected && (
<FolderTree
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}
currentFolder={currentFolder}
expandedKeys={expandedTreeNodes}
onOpen={(_spaceId, path) => openFolder(path)}
onToggle={toggleTreeFolder}
dragDropEnabled={false}
contextMenuEnabled={false}
disabled={loading}
/>
)}
</div>
);
})}
{!loading && spaces.length === 0 && <p className="muted small-note">No accessible file spaces were found.</p>}
</div>
</aside>
<section className={`managed-file-chooser-browser ${mode === "folder" ? "folder-mode" : "attachment-mode"}`}>
<div className="managed-file-chooser-toolbar">
<div className="managed-file-breadcrumb" aria-label="Current folder">
<button type="button" onClick={() => openFolder(effectiveRoot)} disabled={loading}>
<Home size={14} aria-hidden="true" /> {selectedSpace?.label || "Files"}
</button>
{breadcrumbs.map((crumb) => (
<span key={crumb.path}>
<span aria-hidden="true">/</span>
<button type="button" onClick={() => openFolder(crumb.path)} disabled={loading}>{crumb.name}</button>
</span>
))}
</div>
</div>
{mode === "attachment" && (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<div className="field-with-action split-field-action">
<input
value={pattern}
onChange={(event) => { setPattern(event.target.value); setPatternMatches(null); }}
onKeyDown={(event) => { if (event.key === "Enter") void previewPattern(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
<Button onClick={() => void previewPattern()} disabled={resolving || !pattern.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking…" : "Preview"}
</Button>
</div>
</label>
{patternMatches !== null && (
<div className="managed-pattern-summary">
<span>{patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={() => setPatternMatches(null)}>Back to folder</Button>
</div>
)}
</div>
)}
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
) : (
<div className="managed-file-entry-table">
<div className="managed-file-entry-head" role="row">
<span aria-hidden="true" />
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
</div>
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
<button
type="button"
className="managed-file-entry is-folder is-parent"
onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-size"></span>
<span className="managed-file-entry-modified"></span>
</button>
{sortedEntries.map((entry) => {
if (entry.kind === "folder") {
return (
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
<Folder size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && pattern.trim() === exactPattern;
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
return (
<button
type="button"
key={entry.file.id}
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
onClick={() => mode === "attachment" ? requestExactFile(entry.file) : undefined}
disabled={mode === "folder"}
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
</span>
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
</button>
);
})}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
)}
</div>
</div>
)}
{mode === "folder" && (
<p className="muted small-note managed-file-chooser-note">Choose the folder that should act as this campaign attachment source.</p>
)}
{mode === "attachment" && (
<p className="muted small-note managed-file-chooser-note">Clicking a file sets its exact relative path as the pattern. Preview resolves the pattern against the current managed file space.</p>
)}
</section>
</div>
</Dialog>
<ConfirmDialog
open={Boolean(pendingExactFile)}
title="Replace the current pattern?"
message={pendingExactFile ? `Replace “${pattern.trim()}” with the exact file “${relativePath(pendingExactFile.display_path, effectiveRoot)}”?` : "Replace the current pattern?"}
confirmLabel="Replace pattern"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)}
/>
</>
);
return createPortal(dialog, document.body);
}
function ChooserSortButton({
column,
label,
activeColumn,
direction,
onSort,
}: {
column: SortColumn;
label: string;
activeColumn: SortColumn;
direction: SortDirection;
onSort: (column: SortColumn) => void;
}) {
const active = activeColumn === column;
const Icon = active ? (direction === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown;
return (
<button
type="button"
className={active ? "is-sorted" : ""}
aria-label={`${label}: ${active ? `sorted ${direction === "asc" ? "ascending" : "descending"}` : "not sorted"}. Activate to sort.`}
onClick={() => onSort(column)}
>
<span>{label}</span><Icon size={14} aria-hidden="true" />
</button>
);
}
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
return (
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
<div className="file-list-table-head" role="row">
<span>Name</span><span>Size</span><span>Modified</span>
</div>
<div className="file-list-table">
{files.length === 0 && <div className="file-list-empty">No current files match this pattern.</div>}
{files.map((file) => (
<button type="button" className="file-list-row file-row managed-pattern-result-row" role="row" key={file.id} onClick={() => onChoose(file)}>
<span className="file-list-name-cell">
<span className="file-list-name">
<File className="file-row-icon" size={20} aria-hidden="true" />
<span><strong>{file.display_path}</strong>{isSharedWithCampaign(file, campaignId) && <small>Linked to campaign</small>}</span>
</span>
</span>
<span>{formatBytes(file.size_bytes)}</span>
<span className="file-row-tail"><span>{formatDate(file.updated_at)}</span></span>
</button>
))}
</div>
</div>
);
}
function sourceMatches(space: FileSpace, source: ManagedAttachmentSource | null): boolean {
return Boolean(source && space.owner_type === source.ownerType && space.owner_id === source.ownerId);
}
function normalizeManagedBasePath(value: string): string {
const normalized = normalizeFolder(value);
return normalized === "." ? "" : normalized;
}
function relativePath(path: string, root: string): string {
const normalizedPath = normalizeFilePath(path);
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return normalizedPath;
const prefix = `${normalizedRoot}/`;
return normalizedPath.startsWith(prefix) ? normalizedPath.slice(prefix.length) : normalizedPath;
}
function chooserBreadcrumbs(folder: string, root: string): { name: string; path: string }[] {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
const relative = normalizedRoot && normalizedFolder.startsWith(`${normalizedRoot}/`)
? normalizedFolder.slice(normalizedRoot.length + 1)
: normalizedRoot === normalizedFolder ? "" : normalizedFolder;
const parts = relative.split("/").filter(Boolean);
return parts.map((name, index) => ({
name,
path: normalizeFolder([normalizedRoot, ...parts.slice(0, index + 1)].filter(Boolean).join("/"))
}));
}
function parentWithinRoot(folder: string, root: string): string {
const normalizedFolder = normalizeFolder(folder);
const normalizedRoot = normalizeFolder(root);
if (!normalizedFolder || normalizedFolder === normalizedRoot) return normalizedRoot;
const parent = parentFolderPath(normalizedFolder);
return isWithinRoot(parent, normalizedRoot) ? parent : normalizedRoot;
}
function isWithinRoot(path: string, root: string): boolean {
const normalizedPath = normalizeFolder(path);
const normalizedRoot = normalizeFolder(root);
return !normalizedRoot || normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
}
function nodesWithinRoot(nodes: FolderNode[], root: string): FolderNode[] {
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return nodes;
const node = findNode(nodes, normalizedRoot);
return node?.children ?? [];
}
function findNode(nodes: FolderNode[], path: string): FolderNode | null {
for (const node of nodes) {
if (node.path === path) return node;
const child = findNode(node.children, path);
if (child) return child;
}
return null;
}
function isExactPattern(value: string): boolean {
return Boolean(value) && !/[?*\[\]{}]/.test(value);
}
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}
function readRememberedState(key: string): RememberedChooserState {
if (typeof window === "undefined") return {};
try {
const parsed = JSON.parse(window.localStorage.getItem(key) || "{}") as RememberedChooserState;
return parsed && typeof parsed === "object" ? parsed : {};
} catch {
return {};
}
}
function writeRememberedState(key: string, state: RememberedChooserState) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(state));
} catch {
// Storage is optional; chooser behavior remains functional without it.
}
}
function errorMessage(reason: unknown): string {
return reason instanceof Error ? reason.message : String(reason);
}

View File

@@ -0,0 +1,165 @@
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import Button from "../../../components/Button";
export type MessagePreviewAttachment = {
filename?: string | null;
label?: string | null;
detail?: string | null;
contentType?: string | null;
sizeBytes?: number | null;
archiveGroup?: string | null;
archiveLabel?: string | null;
};
export type MessagePreviewMetaItem = {
label: string;
value: React.ReactNode;
};
export type MessagePreviewNavigation = {
index: number;
total: number;
onFirst: () => void;
onPrevious: () => void;
onNext: () => void;
onLast: () => void;
};
export type MessagePreviewOverlayProps = {
title?: string;
subject?: string | null;
bodyMode?: "text" | "html";
text?: string | null;
html?: string | null;
recipientLabel?: React.ReactNode;
recipientNote?: React.ReactNode;
metaItems?: MessagePreviewMetaItem[];
attachments?: MessagePreviewAttachment[];
raw?: string | null;
rawLabel?: string;
navigation?: MessagePreviewNavigation;
closeLabel?: string;
onClose: () => void;
};
export default function MessagePreviewOverlay({
title = "Message preview",
subject,
bodyMode = "text",
text,
html,
recipientLabel,
recipientNote,
metaItems = [],
attachments = [],
raw,
rawLabel = "Raw MIME",
navigation,
closeLabel = "Close",
onClose
}: MessagePreviewOverlayProps) {
const shownSubject = subject?.trim() || "No subject";
const shownText = text?.trim() || "No plain-text body to preview.";
const shownHtml = html?.trim() || "<p>No HTML body to preview.</p>";
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
<div className="modal-panel template-preview-modal message-preview-modal">
<header className="modal-header">
<h2 id="message-preview-title">{title}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
{(recipientLabel || recipientNote || navigation) && (
<div className="template-preview-toolbar">
<div>
{recipientLabel && <strong>{recipientLabel}</strong>}
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
</div>
{navigation && (
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
</div>
)}
</div>
)}
{metaItems.length > 0 && (
<div className="detail-grid message-preview-meta">
{metaItems.map((item) => (
<div key={item.label}><span className="muted small-note">{item.label}</span><strong>{item.value || "—"}</strong></div>
))}
</div>
)}
<div className="template-preview-box">
<h3>{shownSubject}</h3>
{bodyMode === "html" ? (
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={shownHtml} />
) : (
<pre>{shownText}</pre>
)}
</div>
<MessagePreviewAttachmentBoxes attachments={attachments} />
{raw && (
<details className="message-preview-raw">
<summary>{rawLabel}</summary>
<pre className="mock-message-raw">{raw}</pre>
</details>
)}
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
</div>
</div>
);
}
function MessagePreviewAttachmentBoxes({ attachments }: { attachments: MessagePreviewAttachment[] }) {
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
const archiveGroups = new Map<string, MessagePreviewAttachment[]>();
for (const attachment of attachments) {
if (!attachment.archiveGroup) continue;
const group = archiveGroups.get(attachment.archiveGroup) ?? [];
group.push(attachment);
archiveGroups.set(attachment.archiveGroup, group);
}
function attachmentChip(attachment: MessagePreviewAttachment, index: number) {
const filename = attachment.filename?.trim() || attachment.label?.trim() || "Unnamed attachment";
const details = [attachment.detail, attachment.contentType, attachment.sizeBytes ? `${attachment.sizeBytes} bytes` : ""].filter(Boolean).join(" · ");
return (
<div className="attachment-file-chip" key={`${filename}:${index}`}>
<strong>{filename}</strong>
{details && <span>{details}</span>}
</div>
);
}
return (
<div className="template-preview-attachments message-preview-attachments">
<h3>Attachments</h3>
{attachments.length === 0 ? (
<p className="muted small-note">No attachments are effective for this message.</p>
) : (
<div className="message-preview-attachment-layout">
{direct.length > 0 && <div className="attachment-chip-grid">{direct.map(attachmentChip)}</div>}
{[...archiveGroups.entries()].map(([groupName, items]) => (
<section className="attachment-zip-group" key={groupName}>
<header>
<strong>{items[0]?.archiveLabel || groupName}</strong>
<span>{items.length} file{items.length === 1 ? "" : "s"} inside ZIP</span>
</header>
<div className="attachment-chip-grid">{items.map(attachmentChip)}</div>
</section>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import {
buildUndefinedPlaceholders,
extractTemplatePlaceholders,
removePlaceholderFromText,
type TemplateNamespace,
type UndefinedPlaceholder
} from "../utils/templatePlaceholders";
import {
TemplateFieldChipList,
UndefinedPlaceholderDecisionDialog,
UndefinedPlaceholderList,
type TemplateFieldOption
} from "./TemplatePlaceholderControls";
export default function TemplateExpressionEditorDialog({
open,
title,
value,
localFields,
globalFields,
placeholder,
description,
saveLabel = "Save",
validate,
onCancel,
onSave,
onAddField,
canAddField = () => true
}: {
open: boolean;
title: string;
value: string;
localFields: TemplateFieldOption[];
globalFields: TemplateFieldOption[];
placeholder?: string;
description?: string;
saveLabel?: string;
validate?: (value: string) => string;
onCancel: () => void;
onSave: (value: string) => void;
onAddField: (name: string) => void;
canAddField?: (name: string) => boolean;
}) {
const [draftValue, setDraftValue] = useState(value);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!open) return;
setDraftValue(value);
setUndefinedDialog(null);
}, [open, value]);
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(draftValue), [draftValue]);
const localNames = useMemo(() => new Set(localFields.map((field) => field.name)), [localFields]);
const globalNames = useMemo(() => new Set(globalFields.map((field) => field.name)), [globalFields]);
const availableNames = useMemo(() => new Set([...localNames, ...globalNames]), [globalNames, localNames]);
const undefinedPlaceholders = useMemo(
() => buildUndefinedPlaceholders(usedPlaceholders, availableNames, { local: localNames, global: globalNames }),
[availableNames, globalNames, localNames, usedPlaceholders]
);
const validationMessage = validate?.(draftValue) ?? "";
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
const input = inputRef.current;
const token = `{{${namespace}:${name}}}`;
const defaultPosition = draftValue.toLocaleLowerCase().endsWith(".zip") ? draftValue.length - 4 : draftValue.length;
const start = input?.selectionStart ?? defaultPosition;
const end = input?.selectionEnd ?? start;
setDraftValue(`${draftValue.slice(0, start)}${token}${draftValue.slice(end)}`);
window.requestAnimationFrame(() => {
input?.focus();
const cursor = start + token.length;
input?.setSelectionRange(cursor, cursor);
});
}
function requestSave() {
if (validationMessage) return;
if (undefinedPlaceholders.length > 0) {
setUndefinedDialog(undefinedPlaceholders[0]);
return;
}
onSave(draftValue.trim());
}
function removeUndefined(field: UndefinedPlaceholder) {
setDraftValue((current) => removePlaceholderFromText(current, field.raw));
setUndefinedDialog(null);
}
function addUndefined(field: UndefinedPlaceholder) {
if (!field.name || field.reason === "invalid-namespace") return;
onAddField(field.name);
setUndefinedDialog(null);
}
if (!open || typeof document === "undefined") return null;
return createPortal(
<>
<Dialog
open={open && !undefinedDialog}
title={title}
closeLabel="Cancel filename changes"
className="template-expression-dialog"
headerClassName="template-expression-dialog-header"
bodyClassName="template-expression-dialog-body"
footerClassName="template-expression-dialog-footer"
onClose={onCancel}
footer={(
<>
<Button onClick={onCancel}>Cancel</Button>
<Button variant="primary" onClick={requestSave} disabled={Boolean(validationMessage)}>{saveLabel}</Button>
</>
)}
>
{description && <p className="muted template-expression-description">{description}</p>}
<label className="template-expression-value-field">
<span>Archive filename</span>
<input
ref={inputRef}
value={draftValue}
placeholder={placeholder}
aria-invalid={Boolean(validationMessage) || undefined}
onChange={(event) => setDraftValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
requestSave();
}
}}
/>
</label>
{validationMessage && <DismissibleAlert tone="danger" resetKey={validationMessage}>{validationMessage}</DismissibleAlert>}
<h3 className="section-mini-heading">Recipient fields</h3>
<p className="muted small-note">Select a field to insert it at the current cursor position. Address fields represent the first effective address; use <code>to[2]</code> or <code>to[2].email</code> for another single value.</p>
<TemplateFieldChipList
namespace="local"
fields={localFields}
usedPlaceholders={usedPlaceholders}
empty="No recipient fields are available."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Campaign fields</h3>
<TemplateFieldChipList
namespace="global"
fields={globalFields}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values are available."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Used, but undefined</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Dialog>
<UndefinedPlaceholderDecisionDialog
field={undefinedDialog}
contextLabel="archive filename"
removeLabel="Remove from filename"
onCancel={() => setUndefinedDialog(null)}
onRemove={removeUndefined}
onAddField={addUndefined}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
/>
</>,
document.body
);
}

View File

@@ -0,0 +1,118 @@
import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
export type TemplateFieldOption = {
name: string;
label: string;
};
export function TemplateFieldChipList({
namespace,
fields,
usedPlaceholders = [],
empty,
disabled = false,
onInsert
}: {
namespace: TemplateNamespace;
fields: TemplateFieldOption[];
usedPlaceholders?: TemplatePlaceholder[];
empty: string;
disabled?: boolean;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) {
if (fields.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{fields.map((field) => {
const used = usedPlaceholders.some((placeholder) => placeholder.validNamespace && placeholder.namespace === namespace && placeholder.name === field.name);
return (
<button
type="button"
className={`field-chip field-chip-button ${used ? "used" : ""}`}
key={`${namespace}:${field.name}`}
disabled={disabled}
onClick={() => onInsert(namespace, field.name)}
>
<span className="field-chip-namespace">{namespace}</span><span title={field.label !== field.name ? field.label : undefined}>{field.name}</span>
</button>
);
})}
</div>
);
}
export function UndefinedPlaceholderList({
items,
empty = "No undefined placeholders detected.",
onSelect
}: {
items: UndefinedPlaceholder[];
empty?: string;
onSelect: (item: UndefinedPlaceholder) => void;
}) {
if (items.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{items.map((item) => (
<button
type="button"
className="field-chip field-chip-button undefined"
key={`${item.raw}:${item.reason}`}
onClick={() => onSelect(item)}
>
{item.display}
</button>
))}
</div>
);
}
export function UndefinedPlaceholderDecisionDialog({
field,
contextLabel = "template",
removeLabel = "Remove placeholder",
onCancel,
onRemove,
onAddField,
addDisabled = false
}: {
field: UndefinedPlaceholder | null;
contextLabel?: string;
removeLabel?: string;
onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void;
onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean;
}) {
return (
<Dialog
open={Boolean(field)}
title="Undefined field reference"
closeLabel="Return to editing"
className="template-action-dialog"
onClose={onCancel}
footer={field ? (
<>
<Button onClick={onCancel}>Continue editing</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
</>
) : undefined}
>
{field && (
<>
<p>The {contextLabel} uses <code>{`{{${field.raw}}}`}</code>, but it cannot be matched to a known field.</p>
{field.reason === "invalid-namespace" && (
<DismissibleAlert tone="warning">Use the namespace <code>global:</code> or <code>local:</code>.</DismissibleAlert>
)}
{field.reason === "missing-field" && (
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
)}
</>
)}
</Dialog>
);
}

View File

@@ -0,0 +1,127 @@
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { useLocation, useNavigate } from "react-router-dom";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import { useCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
import { formatDateTime } from "../utils/campaignView";
type VersionLineProps = {
version: CampaignVersionDetail | CampaignVersionListItem | null;
versions?: CampaignVersionListItem[];
status?: string;
loadedAt?: string | null;
};
export default function VersionLine({ version, versions = [], status, loadedAt }: VersionLineProps) {
const location = useLocation();
const navigate = useNavigate();
const { requestNavigation } = useCampaignUnsavedChanges();
const sorted = versions.slice().sort((a, b) => (a.version_number ?? 0) - (b.version_number ?? 0));
const currentIndex = version ? sorted.findIndex((item) => item.id === version.id) : -1;
const first = currentIndex > 0 ? sorted[0] : null;
const previous = currentIndex > 0 ? sorted[currentIndex - 1] : null;
const next = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[currentIndex + 1] : null;
const latest = currentIndex >= 0 && currentIndex < sorted.length - 1 ? sorted[sorted.length - 1] : null;
const suffix = status ?? `Loaded ${formatDateTime(loadedAt ?? version?.updated_at)}`;
function openVersion(target: CampaignVersionListItem) {
const targetUrl = versionTarget(location.pathname, location.search, target.id);
requestNavigation(() => navigate(targetUrl));
}
return (
<p className="mono-small version-line">
<VersionArrow
icon="first"
target={first}
fallbackLabel="Already at first version"
onOpen={openVersion}
/>
<VersionArrow
icon="previous"
target={previous}
fallbackLabel="No older version"
onOpen={openVersion}
/>
<span>Version {version ? `#${version.version_number}` : "—"}</span>
<VersionArrow
icon="next"
target={next}
fallbackLabel="No newer version"
onOpen={openVersion}
/>
<VersionArrow
icon="latest"
target={latest}
fallbackLabel="Already at latest version"
onOpen={openVersion}
/>
<span className="version-line-separator">·</span>
<span>{suffix}</span>
</p>
);
}
type VersionArrowProps = {
icon: "first" | "previous" | "next" | "latest";
target: CampaignVersionListItem | null;
fallbackLabel: string;
onOpen: (target: CampaignVersionListItem) => void;
};
function VersionArrow({ icon, target, fallbackLabel, onOpen }: VersionArrowProps) {
const Icon = getVersionIcon(icon);
const actionLabel = getVersionActionLabel(icon, target);
if (!target) {
return (
<span className="version-arrow disabled" title={fallbackLabel} aria-label={fallbackLabel}>
<Icon aria-hidden="true" />
</span>
);
}
return (
<button
type="button"
className="version-arrow"
onClick={() => onOpen(target)}
title={actionLabel}
aria-label={actionLabel}
>
<Icon aria-hidden="true" />
</button>
);
}
function getVersionIcon(icon: VersionArrowProps["icon"]) {
switch (icon) {
case "first":
return ArrowBigLeftDash;
case "previous":
return ArrowBigLeft;
case "next":
return ArrowBigRight;
case "latest":
return ArrowBigRightDash;
}
}
function getVersionActionLabel(icon: VersionArrowProps["icon"], target: CampaignVersionListItem | null): string {
const versionLabel = target ? `version #${target.version_number}` : "version";
switch (icon) {
case "first":
return `Open first ${versionLabel}`;
case "previous":
return `Open previous ${versionLabel}`;
case "next":
return `Open next ${versionLabel}`;
case "latest":
return `Open latest ${versionLabel}`;
}
}
function versionTarget(pathname: string, search: string, versionId: string): string {
const params = new URLSearchParams(search);
params.set("version", versionId);
return `${pathname}?${params.toString()}`;
}

View File

@@ -0,0 +1,178 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import Button from "../../../components/Button";
import DismissibleAlert from "../../../components/DismissibleAlert";
type NavigationAction = () => void;
type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: NavigationAction) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function CampaignUnsavedChangesProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const [registration, setRegistration] = useState<UnsavedChangesRegistration | null>(null);
const [pendingAction, setPendingAction] = useState<NavigationAction | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const registrationRef = useRef<UnsavedChangesRegistration | null>(null);
useEffect(() => {
registrationRef.current = registration;
}, [registration]);
const hasUnsavedChanges = Boolean(registration);
const registerUnsavedChanges = useCallback((next: UnsavedChangesRegistration | null) => {
setRegistration(next);
return () => {
setRegistration((current) => current === next ? null : current);
};
}, []);
const proceed = useCallback((action: NavigationAction) => {
setPendingAction(null);
setSaveError("");
action();
}, []);
const requestNavigation = useCallback((action: NavigationAction) => {
const active = registrationRef.current;
if (!active) {
action();
return;
}
setSaveError("");
setPendingAction(() => action);
}, []);
useEffect(() => {
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!registrationRef.current) return;
event.preventDefault();
event.returnValue = "";
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
useEffect(() => {
function onDocumentClick(event: MouseEvent) {
if (!registrationRef.current) return;
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
const target = event.target as Element | null;
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
if (!anchor) return;
if (anchor.target && anchor.target !== "_self") return;
if (anchor.hasAttribute("download")) return;
if (anchor.getAttribute("href")?.startsWith("#")) return;
const destination = new URL(anchor.href, window.location.href);
const current = new URL(window.location.href);
if (destination.href === current.href) return;
event.preventDefault();
event.stopPropagation();
requestNavigation(() => {
if (destination.origin === current.origin) {
navigate(`${destination.pathname}${destination.search}${destination.hash}`);
} else {
window.location.assign(destination.href);
}
});
}
document.addEventListener("click", onDocumentClick, true);
return () => document.removeEventListener("click", onDocumentClick, true);
}, [navigate, requestNavigation]);
async function handleSaveAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action || !active) return;
setSaving(true);
setSaveError("");
try {
const ok = await active.onSave();
if (!ok) {
setSaveError("The changes could not be saved. Please review the page message and try again.");
return;
}
proceed(action);
} catch (err) {
setSaveError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
function handleDiscardAndLeave() {
const action = pendingAction;
const active = registrationRef.current;
if (!action) return;
active?.onDiscard?.();
proceed(action);
}
const value = useMemo<UnsavedChangesContextValue>(() => ({
hasUnsavedChanges,
registerUnsavedChanges,
requestNavigation
}), [hasUnsavedChanges, registerUnsavedChanges, requestNavigation]);
return (
<UnsavedChangesContext.Provider value={value}>
{children}
{pendingAction && registration && (
<div className="overlay-backdrop" role="dialog" aria-modal="true">
<div className="modal-panel unsaved-changes-dialog">
<header className="modal-header">
<h2>{registration.title ?? "Unsaved campaign changes"}</h2>
<button className="modal-close" onClick={() => setPendingAction(null)} disabled={saving}>×</button>
</header>
<div className="modal-body">
<p>{registration.message ?? "This campaign page has unsaved changes. Save them before leaving, or discard the changes and continue."}</p>
{saveError && <DismissibleAlert tone="danger" resetKey={saveError}>{saveError}</DismissibleAlert>}
</div>
<footer className="modal-footer unsaved-changes-actions">
<Button onClick={() => setPendingAction(null)} disabled={saving}>Cancel</Button>
<Button onClick={handleDiscardAndLeave} disabled={saving}>Discard</Button>
<Button variant="primary" onClick={handleSaveAndLeave} disabled={saving}>{saving ? "Saving…" : "Save and leave"}</Button>
</footer>
</div>
</div>
)}
</UnsavedChangesContext.Provider>
);
}
export function useCampaignUnsavedChanges() {
const context = useContext(UnsavedChangesContext);
if (!context) {
throw new Error("useCampaignUnsavedChanges must be used inside CampaignUnsavedChangesProvider");
}
return context;
}
export function useRegisterCampaignUnsavedChanges(registration: UnsavedChangesRegistration | null) {
const { registerUnsavedChanges } = useCampaignUnsavedChanges();
useEffect(() => {
return registerUnsavedChanges(registration);
}, [registerUnsavedChanges, registration]);
}

View File

@@ -0,0 +1,143 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../../../types";
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
type StepValue = string | (() => string | null | undefined);
type UseCampaignDraftEditorOptions = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | null;
locked: boolean;
reload: () => Promise<void>;
setError: (message: string) => void;
currentStep: StepValue;
currentFlow?: string;
workflowState?: string;
isComplete?: boolean;
unsavedTitle: string;
unsavedMessage: string;
loadedLabel?: (version: CampaignVersionDetail) => string;
transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>;
extraPayload?: () => Partial<CampaignVersionUpdatePayload>;
onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void;
onSaved?: (version: CampaignVersionDetail) => void;
};
function resolveStep(value: StepValue): string | null | undefined {
return typeof value === "function" ? value() : value;
}
function defaultLoadedLabel(version: CampaignVersionDetail): string {
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded";
}
export function useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep,
currentFlow = "manual",
workflowState = "editing",
isComplete = false,
unsavedTitle,
unsavedMessage,
loadedLabel = defaultLoadedLabel,
transformLoadedDraft,
extraPayload,
onLoaded,
onSaved
}: UseCampaignDraftEditorOptions) {
const loadedLabelRef = useRef(loadedLabel);
const transformLoadedDraftRef = useRef(transformLoadedDraft);
const onLoadedRef = useRef(onLoaded);
useEffect(() => {
loadedLabelRef.current = loadedLabel;
transformLoadedDraftRef.current = transformLoadedDraft;
onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, onLoaded]);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
useEffect(() => {
if (!version) return;
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
setDraft(loadedDraft);
setDirty(false);
setLocalError("");
setSaveState(loadedLabelRef.current(version));
onLoadedRef.current?.(version, loadedDraft);
}, [version]);
const markDirty = useCallback(() => {
setDirty(true);
setLocalError("");
}, []);
const patch = useCallback((path: string[], value: unknown) => {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
markDirty();
}, [locked, markDirty]);
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...(extraPayload?.() ?? {})
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: unsavedTitle,
message: unsavedMessage,
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
return {
draft,
setDraft,
displayDraft: draft ?? ensureCampaignDraft(null),
dirty,
setDirty,
saveState,
setSaveState,
localError,
setLocalError,
patch,
markDirty,
saveDraft
};
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings } from "../../../types";
import {
getCampaign,
getCampaignSummary,
getCampaignVersion,
listCampaignVersions,
type CampaignSummary,
type CampaignVersionDetail
} from "../../../api/campaigns";
import type { CampaignWorkspaceData } from "../utils/campaignView";
const initialData: CampaignWorkspaceData = {
campaign: null,
versions: [],
currentVersion: null,
summary: null
};
type UseCampaignWorkspaceDataOptions = {
includeCurrentVersion?: boolean;
includeSummary?: boolean;
includeVersions?: boolean;
};
export function useCampaignWorkspaceData(
settings: ApiSettings,
campaignId: string,
options: UseCampaignWorkspaceDataOptions = {}
) {
const {
includeCurrentVersion = true,
includeSummary = false,
includeVersions = false
} = options;
const [searchParams] = useSearchParams();
const selectedVersionId = searchParams.get("version");
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const reload = useCallback(async () => {
if (!campaignId) return;
setLoading(true);
setError("");
try {
const needsCampaign = includeCurrentVersion || includeVersions;
const shouldLoadVersions = includeCurrentVersion || includeVersions;
const [campaign, versions] = await Promise.all([
needsCampaign ? getCampaign(settings, campaignId) : Promise.resolve(null),
shouldLoadVersions ? listCampaignVersions(settings, campaignId) : Promise.resolve([]),
]);
const wantedVersionId = selectedVersionId || campaign?.current_version_id || versions[0]?.id;
const [versionResult, summaryResult] = await Promise.allSettled([
includeCurrentVersion && wantedVersionId ? getCampaignVersion(settings, campaignId, wantedVersionId) : Promise.resolve(null),
includeSummary ? getCampaignSummary(settings, campaignId, wantedVersionId) : Promise.resolve(null)
]);
setData({
campaign,
versions,
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
});
} catch (err) {
setData(initialData);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]);
useEffect(() => {
reload();
}, [reload]);
return { data, loading, error, reload, setError };
}

View File

@@ -0,0 +1,273 @@
import type { FileSpace } from "../../../api/files";
import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
export type AttachmentRule = Record<string, unknown>;
export type AttachmentZipPasswordScope = "local" | "global";
export type AttachmentZipArchive = {
id: string;
name: string;
standard: boolean;
password_enabled: boolean;
password_field: string;
password_scope: AttachmentZipPasswordScope;
method: "aes" | "zip_standard";
// Read-only compatibility values retained when normalizing older campaigns.
password_mode?: "none" | "direct" | "field" | "template";
password?: string;
password_template?: string;
};
export type AttachmentZipCollection = {
enabled: boolean;
archives: AttachmentZipArchive[];
};
export function createAttachmentZipArchive(name = "attachments.zip", standard = false): AttachmentZipArchive {
return {
id: `zip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
standard,
password_enabled: false,
password_field: "",
password_scope: "local",
method: "aes"
};
}
export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipCollection {
const zip = asRecord(value);
const rawArchives = asArray(zip.archives).filter(isRecord);
if (rawArchives.length > 0 || Object.prototype.hasOwnProperty.call(zip, "archives")) {
return {
enabled: getBool(zip, "enabled"),
archives: rawArchives.map((archive, index) => {
const legacyMode = getText(archive, "password_mode") as AttachmentZipArchive["password_mode"];
return {
id: getText(archive, "id", `zip-${index + 1}`),
name: getText(archive, "name", getText(archive, "filename_template", "attachments.zip")),
standard: getBool(archive, "standard"),
password_enabled: getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode || "")),
password_field: getText(archive, "password_field"),
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
...(legacyMode ? { password_mode: legacyMode } : {}),
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
};
})
};
}
// Upgrade the first, single-archive implementation in memory.
const legacyEnabled = getBool(zip, "enabled");
const legacyName = getText(zip, "filename_template");
const legacyMode = getText(zip, "password_mode", getText(zip, "password_template") ? "template" : "none") as AttachmentZipArchive["password_mode"];
const meaningful = legacyEnabled || Boolean(legacyName || getText(zip, "password") || getText(zip, "password_field") || getText(zip, "password_template"));
if (!meaningful) return { enabled: false, archives: [] };
return {
enabled: legacyEnabled,
archives: [{
id: "default",
name: legacyName || "attachments.zip",
standard: true,
password_enabled: ["direct", "field", "template"].includes(legacyMode || ""),
password_field: getText(zip, "password_field"),
password_scope: "local",
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
...(legacyMode ? { password_mode: legacyMode } : {}),
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
}]
};
}
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
const zip = asRecord(rule.zip);
const archiveId = getText(zip, "archive_id");
if (archiveId) return archiveId;
const legacyMode = getText(zip, "mode");
if (legacyMode === "exclude") return "exclude";
return "inherit";
}
export type ManagedAttachmentSource = {
ownerType: "user" | "group";
ownerId: string;
};
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string {
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
}
export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentSource | null {
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
const [, ownerType, ...ownerIdParts] = value.split(":");
const ownerId = ownerIdParts.join(":").trim();
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
return { ownerType, ownerId };
}
export type AttachmentBasePath = {
id: string;
name: string;
path: string;
source?: string;
allow_individual?: boolean;
unsent_warning?: boolean;
};
export type AttachmentSummary = {
direct: number;
rules: number;
};
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
label: string;
};
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
{ label: "Campaign attachments", path: "attachments" },
{ label: "Campaign root", path: "." },
{ label: "Shared group files", path: "group/shared" },
{ label: "Tenant templates", path: "tenant/templates" },
{ label: "Personal upload area", path: "user/uploads" }
];
export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath {
return {
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
path,
allow_individual: false,
unsent_warning: false
};
}
export function createAttachmentRule(baseDir = "", label = "", basePathId = ""): AttachmentRule {
return {
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label,
base_path_id: basePathId || undefined,
base_dir: baseDir,
file_filter: "",
required: true,
include_subdirs: false,
zip: { archive_id: "inherit" }
};
}
export function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>, fallbackAllowIndividual = false): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual"),
unsent_warning: getBool(basePath, "unsent_warning")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
unsent_warning: false
}];
}
export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
return paths.length > 0 ? paths : [createAttachmentBasePath("Campaign files", ".")];
}
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
return paths.filter((basePath) => basePath.allow_individual);
}
export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: AttachmentBasePath): boolean {
const basePathId = getText(rule, "base_path_id");
if (basePathId) return basePathId === basePath.id;
return getText(rule, "base_dir") === basePath.path;
}
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
.length;
}
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
const entries = asRecord(entriesValue);
const inline = asArray(entries.inline).map(asRecord).map((entry) => ({
...entry,
attachments: normalizeAttachmentRules(entry.attachments).filter((rule) => !attachmentRuleUsesBasePath(rule, basePath))
}));
return { ...entries, inline };
}
export function nextAttachmentLabel(rules: AttachmentRule[]): string {
let highest = 0;
for (const rule of rules) {
const match = /^Attachment\s+(\d+)$/i.exec(getText(rule, "label").trim());
if (match) highest = Math.max(highest, Number(match[1]));
}
highest = Math.max(highest, rules.length);
return `Attachment ${highest + 1}`;
}
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({
id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`),
label: getText(rule, "label"),
base_path_id: getText(rule, "base_path_id"),
base_dir: getText(rule, "base_dir", ""),
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
include_subdirs: getBool(rule, "include_subdirs"),
required: getBool(rule, "required", true),
missing_behavior: getText(rule, "missing_behavior", "ask"),
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
}));
}
export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSummary {
return rules.reduce<AttachmentSummary>((summary, rule) => {
if (isDirectAttachmentRule(rule)) {
summary.direct += 1;
} else {
summary.rules += 1;
}
return summary;
}, { direct: 0, rules: 0 });
}
export function countIndividualAttachmentRules(entriesValue: unknown): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => asArray(entry.attachments))
.length;
}
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
const explicitType = getText(rule, "type");
if (explicitType === "direct") return true;
if (explicitType === "pattern") return false;
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
if (!fileFilter) return false;
return !/[{}*?\[\]]/.test(fileFilter);
}

Some files were not shown because too many files have changed in this diff Show More