intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 3f0b14a726
commit 2e593b7fa4
31 changed files with 3523 additions and 1672 deletions

View File

@@ -15,7 +15,7 @@ This repository owns:
- campaign/version/job/issue/send-attempt/append-attempt models and migrations
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
- WebUI package `@govoplan/campaign-webui`
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, `/address-book`, and `/templates`
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, and `/templates`
Core owns the auth facade, RBAC/capability contracts, database/session
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an

View File

@@ -1,8 +1,8 @@
# Recipient And Address Management Boundary
Campaigns currently own campaign-local recipient entries because sending and
reporting need a frozen recipient snapshot. Long-lived address management is a
separate domain and should move to `govoplan-addresses`.
Campaigns own campaign-local recipient entries because sending and reporting
need a frozen recipient snapshot. Long-lived address management is a separate
domain owned by `govoplan-addresses`.
## `govoplan-campaign` Owns
@@ -16,7 +16,7 @@ separate domain and should move to `govoplan-addresses`.
Campaign data is immutable once a version is built for sending. Later address
book changes must not rewrite historical campaign evidence.
## `govoplan-addresses` Should Own
## `govoplan-addresses` Owns
- Adrema-style address management
- reusable person, organization, household, and postal-address records
@@ -27,15 +27,25 @@ book changes must not rewrite historical campaign evidence.
- import/export of reusable address directories
- address quality checks and change history
The addresses module should provide stable DTOs and capabilities that campaigns,
mail, forms, reporting, portal, and postbox modules can consume without direct
imports.
The addresses module provides the UI/module boundary for this domain and should
grow stable DTOs and capabilities that campaigns, mail, forms, reporting,
portal, and postbox modules can consume without direct imports.
## Integration Contract
## Current Integration Contract
The campaign module should ask the platform whether the addresses module is
installed. When present, campaign can offer address-source choices through a
capability such as `addresses.recipientSource`.
The campaign module asks the platform registry for address capabilities and
keeps working when they are absent. It must not import `govoplan-addresses`
ORM models, services, or WebUI components.
When `addresses.lookup` is available, campaign recipient address fields use it
for autocomplete. The campaign page may warm a small suggestion cache with an
empty query and then query by typed text as the user edits sender, reply-to,
global recipient, or per-recipient address fields.
When `addresses.recipient_source` is available, campaign offers a separate
address-book/list import flow. This is for importing reusable recipient sources
into the campaign recipient table; normal one-off address entry still happens
inside the address fields while typing.
The capability should return snapshots, not live ORM objects:
@@ -49,6 +59,11 @@ Campaign stores the resolved snapshot in the campaign version. It may keep a
reference to the address source for traceability, but the built campaign remains
auditable even if the address source changes later.
If the source revision changes after import, campaign shows a stale-source
warning and lets the user reopen the import dialog with that source preselected.
The user still chooses append or replace; campaign should not silently rewrite
recipient rows.
## Non-Goals For Campaign
Campaign should not become the global address book. It should not own:

View File

@@ -1,8 +1,8 @@
# Recipient Import Guide
Recipient import lets campaign authors turn spreadsheet-like source data into
campaign-local recipient entries. It is intentionally campaign-local today:
reusable address books and Adrema-style address management belong in the future
campaign-local recipient entries. It is intentionally campaign-local:
reusable address books and Adrema-style address management belong in the
`govoplan-addresses` module.
## Supported Inputs
@@ -66,7 +66,7 @@ Administrators should:
- define naming conventions for recurring mapping profiles
- verify that imported fields have a lawful processing basis for the campaign
- keep reusable address-directory ownership out of campaigns until
- keep reusable address-directory ownership out of campaigns because
`govoplan-addresses` owns that domain
- use campaign reports and audit evidence to trace which source produced which
recipient entries

View File

@@ -6,7 +6,14 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_core.mail.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials, TransportSecurity
from govoplan_core.mail.config import (
ImapConfig,
ImapServerConfig,
SmtpConfig,
SmtpServerConfig,
TransportCredentials,
normalize_split_transport_credentials,
)
class StrictModel(BaseModel):
@@ -124,28 +131,7 @@ class ServerConfig(StrictModel):
@model_validator(mode="before")
@classmethod
def normalize_legacy_credentials(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
return normalize_split_transport_credentials(value)
def runtime_smtp_config(self) -> SmtpConfig | None:
if self.smtp is None:
@@ -501,7 +487,11 @@ class ImportProvenance(StrictModel):
id: str
imported_at: str
mode: Literal["append", "replace"]
source_type: Literal["csv", "xlsx", "text"]
source_type: Literal["csv", "xlsx", "text", "addresses"]
source_id: str | None = None
source_label: str | None = None
source_revision: str | None = None
source_provenance: dict[str, Any] = Field(default_factory=dict)
filename: str | None = None
sheet_name: str | None = None
encoding: str | None = None

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import csv
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import Iterable
@@ -8,7 +9,7 @@ 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
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
class Severity(StrEnum):
@@ -51,6 +52,13 @@ class SemanticReport(BaseModel):
return self.error_count == 0
@dataclass(frozen=True, slots=True)
class _EntriesValidation:
mode: str
count: int | None
issues: list[SemanticIssue]
def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue:
return SemanticIssue(severity=severity, code=code, message=message, path=path)
@@ -200,57 +208,95 @@ def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]:
def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]:
collection = config.attachments.zip
issues: list[SemanticIssue] = []
if not collection.enabled:
return issues
return []
if not collection.archives:
return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")]
issues, archive_ids, standard_count = _zip_archive_catalog_issues(config)
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"))
issues.extend(_zip_rule_selection_issues(config, archive_ids))
return issues
def _zip_archive_catalog_issues(config: CampaignConfig) -> tuple[list[SemanticIssue], set[str], int]:
issues: list[SemanticIssue] = []
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):
for index, archive in enumerate(config.attachments.zip.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)
issues.extend(_zip_archive_identity_issues(archive, path, archive_ids, archive_names))
if archive.standard:
standard_count += 1
issues.extend(_zip_password_issues(config, archive, path, field_definitions))
return issues, archive_ids, standard_count
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}"))
def _zip_archive_identity_issues(
archive: ZipArchiveConfig,
path: str,
archive_ids: set[str],
archive_names: set[str],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
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)
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"))
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)
return issues
def _zip_password_issues(
config: CampaignConfig,
archive: ZipArchiveConfig,
path: str,
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
if not archive.password_enabled:
return []
if archive.password_mode == ZipPasswordMode.DIRECT:
if not (archive.password or ""):
return [_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password")]
return []
if archive.password_mode == ZipPasswordMode.TEMPLATE:
if not (archive.password_template or ""):
return [_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template")]
return []
return _zip_password_field_issues(config, archive, path, field_definitions)
def _zip_password_field_issues(
config: CampaignConfig,
archive: ZipArchiveConfig,
path: str,
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
field_name = (archive.password_field or "").strip()
field = field_definitions.get(field_name)
if not field_name:
return [_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")]
if field is None:
return [_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field")]
if getattr(field, "type", None) != FieldType.PASSWORD:
return [_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")]
if archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""):
return [_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}")]
return []
def _zip_rule_selection_issues(config: CampaignConfig, archive_ids: set[str]) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
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:
@@ -276,6 +322,236 @@ def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_pr
]
def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> list[SemanticIssue]:
return [
_issue(
Severity.WARNING,
"unknown_global_value",
f"global_values contains {key!r}, but it is not declared in fields",
f"/global_values/{key}",
)
for key in config.global_values
if declared_names and key not in declared_names
]
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
runtime_imap = config.server.runtime_imap_config()
if config.delivery.imap_append_sent.enabled:
issues.extend(_imap_delivery_issues(runtime_imap))
runtime_smtp = config.server.runtime_smtp_config()
if config.campaign.mode == "send" and not runtime_smtp:
issues.append(
_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
)
)
if runtime_smtp:
missing = [name for name in ["host", "port"] if getattr(runtime_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",
)
)
return issues
def _imap_delivery_issues(runtime_imap: object | None) -> list[SemanticIssue]:
if runtime_imap is None:
return [
_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
"/delivery/imap_append_sent/enabled",
)
]
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
if not missing:
return []
return [
_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
)
]
def _entries_validation(
config: CampaignConfig,
*,
campaign_path: Path,
check_files: bool,
field_names: set[str],
field_definitions: dict[str, object],
) -> _EntriesValidation:
if config.entries.is_inline:
inline_entries = config.entries.inline or []
issues = _inline_entries_issues(config, inline_entries)
return _EntriesValidation(mode="inline", count=len(inline_entries), issues=issues)
issues = _external_entries_issues(
config,
campaign_path=campaign_path,
check_files=check_files,
field_names=field_names,
field_definitions=field_definitions,
)
source_type = config.entries.source.type.value if config.entries.source else "unknown"
return _EntriesValidation(mode=f"external:{source_type}", count=None, issues=issues)
def _inline_entries_issues(config: CampaignConfig, inline_entries: list[EntryConfig]) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
if not inline_entries:
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}"))
return issues
def _external_entries_issues(
config: CampaignConfig,
*,
campaign_path: Path,
check_files: bool,
field_names: set[str],
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
mapping = config.entries.mapping or {}
if not mapping:
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
issues.extend(_mapping_issues(mapping, field_names, field_definitions))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source:
issues.extend(_entries_source_file_issues(config, campaign_path, mapping))
return issues
def _mapping_issues(
mapping: dict[str, str],
field_names: set[str],
field_definitions: dict[str, object],
) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
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)
field = field_definitions.get(field_name or "")
if field_name and field is not None and not getattr(field, "can_override", True):
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}",
)
)
return issues
def _entries_source_file_issues(config: CampaignConfig, campaign_path: Path, mapping: dict[str, str]) -> list[SemanticIssue]:
source = config.entries.source
if source is None:
return []
source_path = _resolve(campaign_path, source.path)
if not source_path.exists():
return [
_issue(
Severity.ERROR,
"entries_source_not_found",
f"entries source file does not exist: {source_path}",
"/entries/source/path",
)
]
if source.type != SourceType.CSV or not source.has_header:
return []
try:
header = _csv_header(source_path, source.delimiter, source.encoding)
except OSError as exc:
return [_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path")]
header_set = set(header or [])
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
if not missing_columns:
return []
return [
_issue(
Severity.ERROR,
"mapping_columns_missing",
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
"/entries/mapping",
)
]
def _file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
issues.extend(_attachment_file_check_issues(config, campaign_path))
issues.extend(_template_source_file_issues(config, campaign_path))
return issues
def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
if config.attachments.base_paths:
return [
_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachment base path {base_path_config.name!r} does not exist: {_resolve(campaign_path, base_path_config.path)}",
f"/attachments/base_paths/{index}/path",
)
for index, base_path_config in enumerate(config.attachments.base_paths)
if not _resolve(campaign_path, base_path_config.path).exists()
]
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
if attachments_base_path.exists():
return []
return [
_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachments.base_path does not exist: {attachments_base_path}",
"/attachments/base_path",
)
]
def _template_source_file_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
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,
)
)
return issues
def validate_campaign_config(
config: CampaignConfig,
*,
@@ -289,149 +565,29 @@ def validate_campaign_config(
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(_global_value_issues(config, declared_names))
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config))
issues.extend(_delivery_issues(config))
runtime_imap = config.server.runtime_imap_config()
if config.delivery.imap_append_sent.enabled:
if runtime_imap is None:
issues.append(_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
"/delivery/imap_append_sent/enabled",
))
else:
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_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",
))
runtime_smtp = config.server.runtime_smtp_config()
if config.campaign.mode == "send" and not runtime_smtp:
issues.append(_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
))
if runtime_smtp:
missing = [name for name in ["host", "port"] if getattr(runtime_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"))
entries = _entries_validation(
config,
campaign_path=campaign_path,
check_files=check_files,
field_names=field_names,
field_definitions=field_definitions,
)
issues.extend(entries.issues)
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,
))
issues.extend(_file_check_issues(config, campaign_path))
report = SemanticReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
issues=issues,
entries_mode=entries_mode,
entries_count=entries_count,
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,

View File

@@ -3,10 +3,17 @@ from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import event, inspect
from sqlalchemy import event
from sqlalchemy.orm import Session as OrmSession
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.sqlalchemy_change_tracking import (
ensure_object_id,
has_attr_changes,
object_state,
operation_for_object,
previous_value,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
@@ -85,10 +92,10 @@ def _record_campaign_change(session: OrmSession, campaign: Campaign) -> None:
def _record_share_visibility_change(session: OrmSession, share: CampaignShare) -> None:
state = inspect(share)
state = object_state(share)
if not share.campaign_id:
return
if not state.deleted and not state.pending and not _has_attr_changes(
if not state.deleted and not state.pending and not has_attr_changes(
state,
("campaign_id", "target_type", "target_id", "permission", "revoked_at"),
):
@@ -271,12 +278,12 @@ def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppen
def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) -> str | None:
state = inspect(obj)
state = object_state(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not _has_attr_changes(state, changed_attrs):
if not has_attr_changes(state, changed_attrs):
return None
status_history = state.attrs.status.history
if status_history.has_changes() and any(value == "deleted" for value in status_history.added):
@@ -285,38 +292,11 @@ def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) ->
def _operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
state = inspect(obj)
if state.pending:
return "created"
if state.deleted:
return "deleted"
if not _has_attr_changes(state, changed_attrs):
return None
return "updated"
def _has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
def _previous_value(obj: object, attr_name: str) -> str | None:
state = inspect(obj)
if attr_name not in state.attrs:
return None
history = state.attrs[attr_name].history
if not history.has_changes() or not history.deleted:
return None
value = history.deleted[0]
return str(value) if value is not None else None
return operation_for_object(obj, changed_attrs=changed_attrs)
def _ensure_id(obj: object) -> str:
resource_id = getattr(obj, "id", None)
if resource_id:
return str(resource_id)
resource_id = new_uuid()
setattr(obj, "id", resource_id)
return resource_id
return ensure_object_id(obj, new_uuid)
def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
@@ -326,12 +306,12 @@ def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
"campaign_id": campaign.id,
"owner_user_id": campaign.owner_user_id,
"owner_group_id": campaign.owner_group_id,
"previous_owner_user_id": _previous_value(campaign, "owner_user_id"),
"previous_owner_group_id": _previous_value(campaign, "owner_group_id"),
"previous_owner_user_id": previous_value(campaign, "owner_user_id"),
"previous_owner_group_id": previous_value(campaign, "owner_group_id"),
"status": campaign.status,
"previous_status": _previous_value(campaign, "status"),
"previous_status": previous_value(campaign, "status"),
"current_version_id": campaign.current_version_id,
"previous_current_version_id": _previous_value(campaign, "current_version_id"),
"previous_current_version_id": previous_value(campaign, "current_version_id"),
}

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from email import policy
from email.message import EmailMessage
from typing import Any
@@ -19,6 +20,30 @@ class MockCampaignSendError(RuntimeError):
pass
@dataclass(slots=True)
class _MockSendOutcome:
row: dict[str, Any]
sent_count: int = 0
failed_count: int = 0
skipped_count: int = 0
imap_appended_count: int = 0
imap_failed_count: int = 0
@dataclass(slots=True)
class _MockSendBatch:
results: list[dict[str, Any]]
sent_count: int = 0
failed_count: int = 0
skipped_count: int = 0
imap_appended_count: int = 0
imap_failed_count: int = 0
@property
def attempted_count(self) -> int:
return self.sent_count + self.failed_count
def _mock_mailbox() -> Any | None:
return mail_integration().mock_mailbox()
@@ -116,6 +141,163 @@ def _can_mock_send(
return False, f"Validation status is {message.validation_status.value}"
def _mock_send_batch(
*,
config: Any,
built_messages: list[Any],
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> _MockSendBatch:
batch = _MockSendBatch(results=[])
for built in built_messages:
outcome = _mock_send_message(
config=config,
built=built,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
batch.results.append(outcome.row)
batch.sent_count += outcome.sent_count
batch.failed_count += outcome.failed_count
batch.skipped_count += outcome.skipped_count
batch.imap_appended_count += outcome.imap_appended_count
batch.imap_failed_count += outcome.imap_failed_count
return batch
def _mock_send_message(
*,
config: Any,
built: Any,
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> _MockSendOutcome:
draft = built.draft
row = _mock_send_row(draft)
can_send, skip_reason = _can_mock_send(
draft,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
)
if not can_send or built.mime is None:
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
return _MockSendOutcome(row=row, skipped_count=1)
recipients = _recipient_emails(draft)
envelope_from = _envelope_from(draft)
if not recipients:
row.update({"status": "skipped", "message": "No envelope recipients"})
return _MockSendOutcome(row=row, skipped_count=1)
if not send:
row.update({
"status": "ready",
"message": f"Would send to {len(recipients)} recipient(s)",
"envelope_from": envelope_from,
"envelope_recipients": recipients,
})
return _MockSendOutcome(row=row)
return _send_mock_smtp_message(
config=config,
built=built,
mailbox=mailbox,
row=row,
recipients=recipients,
envelope_from=envelope_from,
append_sent=append_sent,
)
def _mock_send_row(draft: MessageDraft) -> dict[str, Any]:
return {
"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),
}
def _send_mock_smtp_message(
*,
config: Any,
built: Any,
mailbox: Any | None,
row: dict[str, Any],
recipients: list[str],
envelope_from: str,
append_sent: bool,
) -> _MockSendOutcome:
try:
if mailbox is not None and mailbox.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 = mailbox.record_smtp_delivery(
built.mime,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host="mock.smtp.local",
)
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,
})
imap_appended, imap_failed = _append_mock_sent_message(config, built, mailbox, row, append_sent=append_sent)
return _MockSendOutcome(row=row, sent_count=1, imap_appended_count=imap_appended, imap_failed_count=imap_failed)
except Exception as exc:
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
return _MockSendOutcome(row=row, failed_count=1)
def _append_mock_sent_message(
config: Any,
built: Any,
mailbox: Any | None,
row: dict[str, Any],
*,
append_sent: bool,
) -> tuple[int, int]:
if not append_sent:
return 0, 0
try:
if mailbox is not None and mailbox.consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = _mock_sent_folder(config)
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
return 1, 0
except Exception as exc:
row.update({"imap_status": "failed", "imap_error": str(exc)})
return 0, 1
def _mock_sent_folder(config: Any) -> str:
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
return config.delivery.imap_append_sent.folder
if config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
return config.server.imap.sent_folder
return "Sent"
def run_mock_campaign_send(
session: Session,
*,
@@ -158,7 +340,7 @@ def run_mock_campaign_send(
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-mock-send-",
prefix="govoplan-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)
@@ -166,86 +348,15 @@ def run_mock_campaign_send(
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
files.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 mailbox is not None and mailbox.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 = mailbox.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 mailbox is not None and mailbox.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 = mailbox.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)
send_batch = _mock_send_batch(
config=config,
built_messages=build_result.built_messages,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
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})
@@ -261,7 +372,6 @@ def run_mock_campaign_send(
"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,
@@ -273,19 +383,19 @@ def run_mock_campaign_send(
"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}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if send_batch.failed_count == 0 else "needs_review"), "summary": {"attempted": send_batch.attempted_count, "sent": send_batch.sent_count, "failed": send_batch.failed_count, "skipped": send_batch.skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if send_batch.imap_failed_count == 0 else "needs_review"), "summary": {"appended": send_batch.imap_appended_count, "failed": send_batch.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,
"attempted_count": send_batch.attempted_count,
"sent_count": send_batch.sent_count,
"failed_count": send_batch.failed_count,
"skipped_count": send_batch.skipped_count,
"imap_appended_count": send_batch.imap_appended_count,
"imap_failed_count": send_batch.imap_failed_count,
"results": send_batch.results,
},
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
}

View File

@@ -144,7 +144,7 @@ manifest = ModuleManifest(
name="Campaigns",
version="0.1.8",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("files", "mail"),
optional_dependencies=("files", "mail", "notifications", "addresses"),
provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
@@ -165,6 +165,18 @@ manifest = ModuleManifest(
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.lookup",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.recipient_source",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_campaigns_router,
@@ -175,7 +187,7 @@ manifest = ModuleManifest(
NavItem(
path="/operator",
label="Operator Queue",
icon="activity",
icon="radio-tower",
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
@@ -185,7 +197,7 @@ manifest = ModuleManifest(
),
order=30,
),
NavItem(path="/reports", label="Reports", icon="reports", required_any=("campaigns:report:read",), order=70),
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
),
frontend=FrontendModule(
module_id="campaigns",
@@ -195,7 +207,7 @@ manifest = ModuleManifest(
NavItem(
path="/operator",
label="Operator Queue",
icon="activity",
icon="radio-tower",
required_any=(
"campaigns:campaign:queue",
"campaigns:campaign:retry",
@@ -205,9 +217,8 @@ manifest = ModuleManifest(
),
order=30,
),
NavItem(path="/reports", label="Reports", icon="reports", 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),
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
NavItem(path="/templates", label="Templates", icon="layout-template", order=90),
),
),
migration_spec=MigrationSpec(

View File

@@ -81,6 +81,33 @@ class CampaignBuildResult:
built_messages: list[BuiltMessage]
@dataclass(slots=True)
class _EntryMessageContext:
resolution: EntryAttachmentResolution
senders: list[RecipientConfig]
sender: RecipientConfig | None
recipients: dict[str, list[RecipientConfig]]
issues: list[MessageIssue]
validation_status: MessageValidationStatus
@dataclass(slots=True)
class _RenderedMessageTemplate:
subject: str
text_body: str | None
html_body: str | None
body_mode: str
values: dict[str, Any]
@dataclass(slots=True)
class _MimeBuildResult:
message: EmailMessage | None
build_status: BuildStatus
validation_status: MessageValidationStatus
attachment_count: int
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser()
@@ -431,6 +458,322 @@ def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entr
return str(path), path.stat().st_size
def _entry_message_context(
*,
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
attachment_match_index: AttachmentMatchIndex | None,
) -> _EntryMessageContext:
resolution = resolve_entry_attachments(
config=config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
match_index=attachment_match_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)
validation_status = _apply_ignored_field_override_issues(config, entry, issues, validation_status)
return _EntryMessageContext(
resolution=resolution,
senders=senders,
sender=sender,
recipients=recipients,
issues=issues,
validation_status=validation_status,
)
def _apply_ignored_field_override_issues(
config: CampaignConfig,
entry: EntryConfig,
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
if not ignored_field_overrides:
return validation_status
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:
return MessageValidationStatus.WARNING
return validation_status
def _message_draft(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
context: _EntryMessageContext,
build_status: BuildStatus,
validation_status: MessageValidationStatus,
send_status: SendStatus = SendStatus.DRAFT,
imap_status: ImapStatus | None = None,
subject: str | None = None,
attachment_count: int = 0,
issues: list[MessageIssue] | None = None,
eml_path: str | None = None,
eml_size: int | None = None,
) -> MessageDraft:
if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
return MessageDraft(
entry_index=entry_index,
entry_id=entry.id,
active=entry.active,
build_status=build_status,
validation_status=validation_status,
send_status=send_status,
imap_status=imap_status,
subject=subject,
from_=_message_address(context.sender),
from_all=_message_addresses(context.senders),
to=_message_addresses(context.recipients["to"]),
cc=_message_addresses(context.recipients["cc"]),
bcc=_message_addresses(context.recipients["bcc"]),
reply_to=_message_addresses(context.recipients["reply_to"]),
bounce_to=_message_addresses(context.recipients["bounce_to"]),
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
attachment_count=attachment_count,
attachments=_attachment_summaries(context.resolution),
issues=issues if issues is not None else context.issues,
eml_path=eml_path,
eml_size_bytes=eml_size,
)
def _inactive_entry_message(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
context: _EntryMessageContext,
) -> BuiltMessage:
draft = _message_draft(
config=config,
entry=entry,
entry_index=entry_index,
context=context,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.INACTIVE,
imap_status=ImapStatus.SKIPPED,
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)
def _validate_required_recipients(
config: CampaignConfig,
recipients: dict[str, list[RecipientConfig]],
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
if recipients["to"]:
return validation_status
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",
)
)
if behavior == MissingAddressBehavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
return MessageValidationStatus.EXCLUDED
def _render_message_template(
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
) -> _RenderedMessageTemplate:
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
body_mode = _template_body_mode(config)
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 = None
html_body = None
if text_template is not None:
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders)
if html_template is not None:
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders)
return _RenderedMessageTemplate(
subject=subject,
text_body=text_body,
html_body=html_body,
body_mode=body_mode,
values=values,
)
def _unresolved_template_fields(rendered: _RenderedMessageTemplate) -> list[str]:
unresolved_fields = _find_unresolved_placeholders(rendered.subject)
if rendered.body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(rendered.text_body)
if rendered.body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(rendered.html_body)
return sorted(unresolved_fields)
def _validate_rendered_template(
config: CampaignConfig,
rendered: _RenderedMessageTemplate,
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
unresolved = _unresolved_template_fields(rendered)
if not unresolved:
return validation_status
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",
)
)
if behavior == MissingAddressBehavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
return MessageValidationStatus.EXCLUDED
def _populate_message_headers(
message: EmailMessage,
*,
senders: list[RecipientConfig],
recipients: dict[str, list[RecipientConfig]],
subject: str,
) -> None:
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
def _populate_message_body(message: EmailMessage, rendered: _RenderedMessageTemplate) -> None:
effective_html_body = rendered.html_body if rendered.html_body and rendered.html_body.strip() else None
if rendered.body_mode == TemplateBodyMode.HTML.value:
message.set_content(effective_html_body or "", subtype="html")
elif rendered.body_mode == TemplateBodyMode.TEXT.value:
message.set_content(rendered.text_body or "")
elif effective_html_body is not None:
message.set_content(rendered.text_body or "")
message.add_alternative(effective_html_body, subtype="html")
else:
message.set_content(rendered.text_body or "")
def _build_mime_message(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
output_dir: Path | None,
work_dir: Path | None,
context: _EntryMessageContext,
rendered: _RenderedMessageTemplate,
) -> _MimeBuildResult:
message = EmailMessage()
try:
_populate_message_headers(
message,
senders=context.senders,
recipients=context.recipients,
subject=rendered.subject,
)
_populate_message_body(message, rendered)
if work_dir is None:
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
attachment_count = _attach_files(
message=message,
config=config,
entry=entry,
entry_index=entry_index,
resolution=context.resolution,
values=rendered.values,
work_dir=work_dir,
)
return _MimeBuildResult(
message=message,
build_status=BuildStatus.BUILT,
validation_status=context.validation_status,
attachment_count=attachment_count,
)
except ZipBuildError as exc:
context.issues.append(
MessageIssue(
severity="error",
code=exc.code,
message=str(exc),
behavior="block",
source="attachments",
)
)
except Exception as exc:
context.issues.append(
MessageIssue(
severity="error",
code="build_failed",
message=str(exc),
behavior="block",
source="builder",
)
)
return _MimeBuildResult(
message=None,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.BLOCKED,
attachment_count=0,
)
def build_entry_message(
*,
config: CampaignConfig,
@@ -442,169 +785,57 @@ def build_entry_message(
work_dir: Path | None = None,
attachment_match_index: AttachmentMatchIndex | None = None,
) -> BuiltMessage:
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index, match_index=attachment_match_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
context = _entry_message_context(
config=config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
attachment_match_index=attachment_match_index,
)
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)
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
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)
body_mode = _template_body_mode(config)
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_fields = _find_unresolved_placeholders(subject)
if body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(text_body)
if body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(html_body)
unresolved = sorted(unresolved_fields)
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
effective_html_body = html_body if html_body and html_body.strip() else None
if body_mode == TemplateBodyMode.HTML.value:
message.set_content(effective_html_body or "", subtype="html")
elif body_mode == TemplateBodyMode.TEXT.value:
message.set_content(text_body or "")
elif effective_html_body is not None:
message.set_content(text_body or "")
message.add_alternative(effective_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]
context.validation_status = _validate_required_recipients(
config,
context.recipients,
context.issues,
context.validation_status,
)
rendered = _render_message_template(config, campaign_file, entry)
context.validation_status = _validate_rendered_template(
config,
rendered,
context.issues,
context.validation_status,
)
mime_result = _build_mime_message(
config=config,
entry=entry,
entry_index=entry_index,
output_dir=output_dir,
work_dir=work_dir,
context=context,
rendered=rendered,
)
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)
if write_eml and output_dir is not None and mime_result.message is not None:
eml_path, eml_size = _write_eml(mime_result.message, output_dir, entry, entry_index)
draft = MessageDraft(
draft = _message_draft(
config=config,
entry=entry,
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,
context=context,
build_status=mime_result.build_status,
validation_status=mime_result.validation_status,
subject=rendered.subject,
attachment_count=mime_result.attachment_count,
eml_path=eml_path,
eml_size_bytes=eml_size,
eml_size=eml_size,
)
return BuiltMessage(draft=draft, mime=message)
return BuiltMessage(draft=draft, mime=mime_result.message)
@@ -680,7 +911,7 @@ def build_campaign_messages(
started = time.perf_counter()
attachment_match_index = AttachmentMatchIndex()
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
with tempfile.TemporaryDirectory(prefix="govoplan-build-") as tmp:
work_dir = output_path or Path(tmp)
built_messages = [
build_entry_message(

View File

@@ -275,7 +275,7 @@ def validate_campaign_version(
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=False,
prefix="multimailer-managed-validate-",
prefix="govoplan-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)
@@ -407,7 +407,7 @@ def build_campaign_version(
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-managed-build-",
prefix="govoplan-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)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import copy
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
@@ -31,6 +32,39 @@ class LockedCampaignVersionError(CampaignPersistenceError):
"""Raised when a caller tries to edit an immutable campaign version."""
@dataclass(slots=True)
class _PartialValidationCollector:
section: str | None
issues: list[dict[str, Any]] = field(default_factory=list)
def issue(self, severity: str, section: str, field: str, code: str, message: str) -> None:
if self.section is None or self.section == section:
self.issues.append({
"severity": severity,
"section": section,
"field": field,
"code": code,
"message": message,
})
def result(self) -> dict[str, Any]:
return {
"ok": not any(item["severity"] == "error" for item in self.issues),
"section": self.section,
"error_count": sum(1 for item in self.issues if item["severity"] == "error"),
"warning_count": sum(1 for item in self.issues if item["severity"] == "warning"),
"info_count": sum(1 for item in self.issues if item["severity"] == "info"),
"issues": self.issues,
}
def _require_campaign(session: Session, campaign_id: str) -> Campaign:
campaign = session.get(Campaign, campaign_id)
if campaign is None:
raise CampaignPersistenceError(f"Campaign not found: {campaign_id}")
return campaign
USER_LOCK_TEMPORARY = "temporary"
USER_LOCK_PERMANENT = "permanent"
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
@@ -320,8 +354,7 @@ def fork_campaign_version_for_edit(
"""
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
campaign = _require_campaign(session, campaign_id)
if campaign_has_active_working_version(session, campaign):
current = session.get(CampaignVersion, campaign.current_version_id)
@@ -429,8 +462,7 @@ def unlock_validated_campaign_version(
"""
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
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="unlock")
if is_temporary_user_locked_version(version):
@@ -490,8 +522,7 @@ def update_campaign_version(
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
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="edit")
if is_version_locked(version):
@@ -566,64 +597,95 @@ def update_campaign_review_state(
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
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_token = _campaign_review_build_token(version)
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
if inspection_complete:
normalized_reviewed = _complete_campaign_review_keys(session, version, normalized_reviewed)
_write_campaign_review_state(
version,
build_token=build_token,
inspection_complete=inspection_complete,
reviewed_message_keys=normalized_reviewed,
user_id=user_id,
)
session.add(version)
session.commit()
return version
def _campaign_review_build_token(version: CampaignVersion) -> str:
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
if build_token:
return build_token
build_token = uuid4().hex
updated_summary = copy.deepcopy(build_summary)
updated_summary["build_token"] = build_token
version.build_summary = updated_summary
return build_token
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()
def _complete_campaign_review_keys(
session: Session,
version: CampaignVersion,
reviewed_message_keys: list[str],
) -> list[str]:
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.")
missing = sorted(_required_review_keys(jobs) - set(reviewed_message_keys))
if missing:
raise CampaignPersistenceError(
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
)
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]))
return list(dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]))
def _required_review_keys(jobs: list[CampaignJob]) -> set[str]:
return {
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status == "needs_review"
}
def _bulk_acceptable_review_keys(jobs: list[CampaignJob]) -> list[str]:
return [
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status in {"warning", "excluded"}
]
def _write_campaign_review_state(
version: CampaignVersion,
*,
build_token: str,
inspection_complete: bool,
reviewed_message_keys: list[str],
user_id: str | None,
) -> None:
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,
"reviewed_message_keys": reviewed_message_keys,
"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(
@@ -642,8 +704,7 @@ def lock_campaign_version_temporarily(
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
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.")
@@ -677,8 +738,7 @@ def unlock_user_locked_campaign_version(
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
ensure_current_working_version(campaign, version, action="unlock")
state = campaign_version_user_lock_state(version)
if state == USER_LOCK_PERMANENT:
@@ -716,8 +776,7 @@ def permanently_lock_campaign_version(
campaign_id=campaign_id,
version_id=version_id,
)
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
campaign = _require_campaign(session, campaign_id)
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.")
@@ -761,79 +820,101 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
lets the WebUI autosave and validate one wizard step at a time.
"""
issues: list[dict[str, Any]] = []
collector = _PartialValidationCollector(section=section)
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
recipients = _dict_value(raw_json, "recipients")
_validate_partial_sender(collector, recipients)
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
_validate_partial_template(collector, _dict_value(raw_json, "template"))
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
_validate_partial_delivery(collector, _dict_value(raw_json, "delivery"))
return collector.result()
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 {}
def _dict_value(value: dict[str, Any], key: str) -> dict[str, Any]:
candidate = value.get(key)
return candidate if isinstance(candidate, dict) else {}
def _validate_partial_basics(collector: _PartialValidationCollector, campaign: dict[str, Any]) -> None:
if not campaign.get("id"):
issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
collector.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.")
collector.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 {}
def _validate_partial_sender(collector: _PartialValidationCollector, recipients: dict[str, Any]) -> None:
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.")
collector.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 {}
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
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.")
collector.issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
if has_source and not _entries_source_has_email_mapping(entries):
collector.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 {}
def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool:
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
return any(key in mapping for key in ("to.0.email", "to.email", "email"))
def _validate_partial_template(collector: _PartialValidationCollector, template: dict[str, Any]) -> None:
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
if not template.get("subject") and not source_template.get("subject_path"):
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
explicit_body_mode = template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None
has_text_body = bool(template.get("text")) or bool(source_template.get("text_path"))
has_html_body = bool(template.get("html")) or bool(source_template.get("html_path"))
if explicit_body_mode == "text" and not has_text_body:
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
elif explicit_body_mode == "html" and not has_html_body:
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
elif explicit_body_mode == "both":
if not has_text_body:
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
if not has_html_body:
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
elif not has_text_body and not has_html_body and not source_template:
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
collector.issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
body_state = _partial_template_body_state(template, source_template)
_validate_partial_template_body(collector, body_state)
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
def _partial_template_body_state(template: dict[str, Any], source_template: dict[str, Any]) -> dict[str, Any]:
return {
"mode": template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None,
"has_text": bool(template.get("text")) or bool(source_template.get("text_path")),
"has_html": bool(template.get("html")) or bool(source_template.get("html_path")),
"has_source": bool(source_template),
}
def _validate_partial_template_body(collector: _PartialValidationCollector, state: dict[str, Any]) -> None:
mode = state["mode"]
has_text = bool(state["has_text"])
has_html = bool(state["has_html"])
if mode == "text" and not has_text:
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
elif mode == "html" and not has_html:
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
elif mode == "both":
_validate_partial_dual_body_template(collector, has_text=has_text, has_html=has_html)
elif not has_text and not has_html and not state["has_source"]:
collector.issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
def _validate_partial_dual_body_template(collector: _PartialValidationCollector, *, has_text: bool, has_html: bool) -> None:
if not has_text:
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
if not has_html:
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
def _validate_partial_attachments(collector: _PartialValidationCollector, attachments: dict[str, Any]) -> None:
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.")
collector.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 {}
def _validate_partial_delivery(collector: _PartialValidationCollector, delivery: dict[str, Any]) -> None:
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,
}
if messages_per_minute is None:
return
try:
if int(messages_per_minute) < 1:
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
except (TypeError, ValueError):
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")

View File

@@ -367,38 +367,127 @@ def generate_campaign_report(
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
version = _selected_version(session, campaign, version_id)
jobs = _report_jobs(session, tenant_id=tenant_id, campaign_id=campaign.id, version=version)
report = _campaign_report_payload(
session,
campaign=campaign,
version=version,
jobs=jobs,
tenant_id=tenant_id,
include_recent_failures=include_recent_failures,
)
if include_jobs:
report["jobs"] = [_job_row(job) for job in jobs]
return report
def _report_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> list[CampaignJob]:
jobs_query = session.query(CampaignJob).filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.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()
)
return jobs_query.order_by(CampaignJob.entry_index.asc()).all()
def _campaign_report_payload(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion | None,
jobs: list[CampaignJob],
tenant_id: str,
include_recent_failures: bool,
) -> dict[str, Any]:
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
report = {
"generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign),
"current_version": _version_info(version),
"selected_version_id": version.id if version else None,
"cards": _campaign_report_cards(version, jobs),
"status_counts": _campaign_report_status_counts(version, jobs),
"issues": {
**_issue_summary_from_jobs(jobs),
"persisted_campaign_issue_count": _persisted_campaign_issue_count(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
},
"attachments": _attachment_summary(jobs),
"attempts": _campaign_report_attempt_counts(session, job_ids),
"delivery": _load_delivery_info(version, jobs),
}
if include_recent_failures:
report["recent_failures"] = _recent_failures(jobs)
return report
def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
return {
"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,
}
def _campaign_report_attempt_counts(session: Session, job_ids: list[str]) -> dict[str, int]:
return {
"send_attempts": int(session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
"imap_append_attempts": int(session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
}
def _persisted_campaign_issue_count(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> int:
issue_query = session.query(CampaignIssue).filter(
CampaignIssue.tenant_id == tenant_id,
CampaignIssue.campaign_id == campaign.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()
return int(issue_query.count())
def _campaign_report_status_counts(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, dict[str, int]]:
validation_counts = _counter([job.validation_status for job in jobs])
queue_counts = _counter([job.queue_status for job in jobs])
inactive_entries = _inactive_entry_count(version)
if inactive_entries:
validation_counts["inactive"] = inactive_entries
return {
"build": _counter([job.build_status for job in jobs]),
"validation": validation_counts,
"queue": _counter([job.queue_status for job in jobs]),
"send": _counter([job.send_status for job in jobs]),
"imap": _counter([job.imap_status for job in jobs]),
}
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
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
@@ -413,63 +502,28 @@ def generate_campaign_report(
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),
inactive_entries = _inactive_entry_count(version)
return {
"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),
}
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 _inactive_entry_count(version: CampaignVersion | None) -> int:
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
def generate_jobs_csv(

View File

@@ -105,7 +105,7 @@ def _text_summary(report: dict[str, Any]) -> str:
]
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."])
lines.extend(["", "This report was generated by GovOPlaN."])
return "\n".join(lines)
@@ -119,20 +119,20 @@ def build_report_message(
report_json: dict[str, Any] | None = None,
) -> EmailMessage:
from_email, from_name = _effective_from(config)
subject = f"MultiMailer report: {campaign.name}"
subject = f"GovOPlaN 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["X-GovOPlaN-Report"] = "campaign"
msg.set_content(_text_summary(report))
if jobs_csv is not None:
filename = f"multimailer-{campaign.external_id}-jobs.csv"
filename = f"govoplan-{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"
filename = f"govoplan-{campaign.external_id}-report.json"
msg.add_attachment(
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
maintype="application",

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://multimailer.local/schema/campaign.schema.json",
"title": "MultiMailer Campaign",
"$id": "https://govoplan.local/schema/campaign.schema.json",
"title": "GovOPlaN Campaign",
"type": "object",
"required": [
"version",
@@ -1074,9 +1074,32 @@
"enum": [
"csv",
"xlsx",
"text"
"text",
"addresses"
]
},
"source_id": {
"type": [
"string",
"null"
]
},
"source_label": {
"type": [
"string",
"null"
]
},
"source_revision": {
"type": [
"string",
"null"
]
},
"source_provenance": {
"type": "object",
"default": {}
},
"filename": {
"type": [
"string",

View File

@@ -266,6 +266,65 @@ class RecipientImportMappingProfileListResponse(BaseModel):
profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list)
class CampaignAddressLookupCandidate(BaseModel):
contact_id: str
address_book_id: str
display_name: str
email: str | None = None
email_label: str | None = None
organization: str | None = None
role_title: str | None = None
tags: list[str] = Field(default_factory=list)
source_kind: str = "local"
source_ref: str | None = None
source_revision: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignAddressLookupResponse(BaseModel):
available: bool = False
candidates: list[CampaignAddressLookupCandidate] = Field(default_factory=list)
class CampaignRecipientAddressSource(BaseModel):
source_id: str
source_label: str
source_kind: str
source_revision: str
recipient_count: int = 0
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourcesResponse(BaseModel):
available: bool = False
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_id: str = Field(min_length=1)
class CampaignRecipientSnapshotItem(BaseModel):
contact_id: str
display_name: str
email: str
email_label: str | None = None
fields: dict[str, Any] = Field(default_factory=dict)
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
source_id: str
source_label: str
source_kind: str
source_revision: str
generated_at: str
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
provenance: dict[str, Any] = Field(default_factory=dict)
class CampaignJobsResponse(BaseModel):
jobs: list[dict[str, Any]]
page: int = 1

View File

@@ -219,8 +219,10 @@ def create_execution_snapshot(
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)
if not isinstance(redacted_smtp, SmtpConfig):
raise ExecutionSnapshotError("Redacted SMTP configuration is invalid.")
if redacted_imap is not None and not isinstance(redacted_imap, ImapConfig):
raise ExecutionSnapshotError("Redacted IMAP configuration is invalid.")
payload = ExecutionSnapshot(
campaign_version_id=version.id,
campaign_json_sha256=_sha256(raw_json),

View File

@@ -12,6 +12,7 @@ from uuid import uuid4
from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import (
Campaign,
@@ -28,6 +29,7 @@ from govoplan_campaign.backend.db.models import (
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.integrations import (
ImapAppendError,
ImapConfigurationError,
@@ -133,6 +135,25 @@ class AppendSentResult:
}
@dataclass(frozen=True, slots=True)
class _CampaignDeliveryCounts:
accepted: int
unknown: int
failed: int
active: int
cancelled: int
not_started: int
@dataclass(frozen=True, slots=True)
class _SendJobDeliveryContext:
version: CampaignVersion
snapshot: ExecutionSnapshot
message_bytes: bytes
envelope_from: str
envelope_recipients: list[str]
QUEUEABLE_VALIDATION_STATUSES = {
JobValidationStatus.READY.value,
JobValidationStatus.WARNING.value,
@@ -140,6 +161,15 @@ QUEUEABLE_VALIDATION_STATUSES = {
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
CAMPAIGN_STATUS_NOTIFICATION_EVENTS = {
CampaignStatus.QUEUED.value: ("campaign.queued", "Campaign queued", 1),
CampaignStatus.SENDING.value: ("campaign.sending", "Campaign sending started", 1),
CampaignStatus.SENT.value: ("campaign.sent", "Campaign sent", 1),
CampaignStatus.PARTIALLY_COMPLETED.value: ("campaign.partially_completed", "Campaign partially completed", 4),
CampaignStatus.OUTCOME_UNKNOWN.value: ("campaign.outcome_unknown", "Campaign requires review", 5),
CampaignStatus.FAILED.value: ("campaign.failed", "Campaign failed", 5),
CampaignStatus.CANCELLED.value: ("campaign.cancelled", "Campaign cancelled", 2),
}
def _version_user_lock_state(version: CampaignVersion) -> str | None:
@@ -210,16 +240,72 @@ def _should_enqueue_celery(enqueue_celery: bool) -> bool:
return bool(enqueue_celery and _celery_enabled())
def _campaign_notification_body(campaign: Campaign, status: str) -> str:
return {
CampaignStatus.QUEUED.value: f"{campaign.name} has been queued for delivery.",
CampaignStatus.SENDING.value: f"{campaign.name} started sending.",
CampaignStatus.SENT.value: f"{campaign.name} finished sending.",
CampaignStatus.PARTIALLY_COMPLETED.value: f"{campaign.name} finished with partial delivery results. Review the campaign report.",
CampaignStatus.OUTCOME_UNKNOWN.value: f"{campaign.name} has unresolved delivery outcomes and needs operator review.",
CampaignStatus.FAILED.value: f"{campaign.name} failed during delivery. Review the failed jobs.",
CampaignStatus.CANCELLED.value: f"{campaign.name} was cancelled.",
}.get(status, f"{campaign.name} changed status to {status}.")
def _emit_campaign_status_notification(
session: Session,
*,
campaign: Campaign,
status: str,
previous_status: str | None = None,
version_id: str | None = None,
message: str | None = None,
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
event = CAMPAIGN_STATUS_NOTIFICATION_EVENTS.get(status)
if event is None:
return
event_kind, title, priority = event
try:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=campaign.tenant_id,
source_module="campaigns",
source_resource_type="campaign",
source_resource_id=campaign.id,
event_kind=event_kind,
channel="inbox",
subject=f"{title}: {campaign.name}",
body_text=message or _campaign_notification_body(campaign, status),
action_url=f"/campaigns/{campaign.id}",
priority=priority,
payload={
"campaign_id": campaign.id,
"version_id": version_id or campaign.current_version_id,
"status": status,
"previous_status": previous_status,
},
metadata={"campaign_id": campaign.id, "version_id": version_id or campaign.current_version_id},
),
enqueue_delivery=False,
)
except Exception:
return
def _celery_enqueue_send_job(job_id: str) -> None:
from govoplan_core.celery_app import celery
celery.send_task("multimailer.send_email", args=[job_id], queue="send_email")
celery.send_task("govoplan.campaigns.send_email", args=[job_id], queue="send_email")
def _celery_enqueue_append_sent_job(job_id: str) -> None:
from govoplan_core.celery_app import celery
celery.send_task("multimailer.append_sent", args=[job_id], queue="append_sent")
celery.send_task("govoplan.campaigns.append_sent", args=[job_id], queue="append_sent")
def queue_campaign_jobs(
@@ -296,11 +382,20 @@ def queue_campaign_jobs(
if not dry_run:
if queued:
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
if version.locked_at is None:
version.locked_at = _utcnow()
session.add(version)
if previous_status != campaign.status:
_emit_campaign_status_notification(
session,
campaign=campaign,
status=campaign.status,
previous_status=previous_status,
version_id=version.id,
)
session.add(campaign)
session.commit()
@@ -469,8 +564,16 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str,
job.send_status = JobSendStatus.QUEUED.value
session.add(job)
if jobs:
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
session.add(campaign)
if previous_status != campaign.status:
_emit_campaign_status_notification(
session,
campaign=campaign,
status=campaign.status,
previous_status=previous_status,
)
session.commit()
enqueued_count = 0
@@ -909,27 +1012,54 @@ def _update_campaign_after_job(session: Session, campaign_id: str, version_id: s
campaign = session.get(Campaign, campaign_id)
if not campaign:
return
base_filters = [CampaignJob.campaign_id == campaign_id]
if version_id:
base_filters.append(CampaignJob.campaign_version_id == version_id)
previous_status = campaign.status
version = session.get(CampaignVersion, version_id) if version_id else None
counts = _campaign_delivery_counts(session, campaign_id=campaign_id, version_id=version_id)
_apply_campaign_delivery_status(campaign, version, counts)
if version and (counts.accepted or counts.unknown):
if version.locked_at is None:
version.locked_at = _utcnow()
session.add(version)
session.add(campaign)
if campaign.status != previous_status:
_emit_campaign_status_notification(
session,
campaign=campaign,
status=campaign.status,
previous_status=previous_status,
version_id=version_id,
)
def _campaign_delivery_counts(session: Session, *, campaign_id: str, version_id: str | None) -> _CampaignDeliveryCounts:
base_filters = _campaign_delivery_base_filters(campaign_id=campaign_id, version_id=version_id)
counts = {
status: session.query(CampaignJob).filter(*base_filters, CampaignJob.send_status == status).count()
for status in {item.value for item in JobSendStatus}
}
accepted = sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES)
unknown = counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0)
failed = counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0)
active = (
counts.get(JobSendStatus.QUEUED.value, 0)
+ counts.get(JobSendStatus.CLAIMED.value, 0)
+ counts.get(JobSendStatus.SENDING.value, 0)
return _CampaignDeliveryCounts(
accepted=sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES),
unknown=counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0),
failed=counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0),
active=(
counts.get(JobSendStatus.QUEUED.value, 0)
+ counts.get(JobSendStatus.CLAIMED.value, 0)
+ counts.get(JobSendStatus.SENDING.value, 0)
),
cancelled=counts.get(JobSendStatus.CANCELLED.value, 0),
not_started=_queueable_not_started_job_count(session, base_filters),
)
cancelled = counts.get(JobSendStatus.CANCELLED.value, 0)
# Blocked/excluded build rows are reportable but are not delivery attempts.
# Only a built, queueable message counts as an unattempted part of an
# execution when deriving a partial outcome.
not_started = (
def _campaign_delivery_base_filters(*, campaign_id: str, version_id: str | None) -> list[object]:
base_filters: list[object] = [CampaignJob.campaign_id == campaign_id]
if version_id:
base_filters.append(CampaignJob.campaign_version_id == version_id)
return base_filters
def _queueable_not_started_job_count(session: Session, base_filters: list[object]) -> int:
return (
session.query(CampaignJob)
.filter(
*base_filters,
@@ -940,37 +1070,35 @@ def _update_campaign_after_job(session: Session, campaign_id: str, version_id: s
.count()
)
version = session.get(CampaignVersion, version_id) if version_id else None
if active:
campaign.status = CampaignStatus.SENDING.value
if version:
version.workflow_state = CampaignVersionWorkflowState.SENDING.value
elif accepted and (failed or unknown or cancelled or not_started):
campaign.status = CampaignStatus.PARTIALLY_COMPLETED.value
if version:
version.workflow_state = CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value
elif unknown:
campaign.status = CampaignStatus.OUTCOME_UNKNOWN.value
if version:
version.workflow_state = CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value
elif failed:
campaign.status = CampaignStatus.FAILED.value
if version:
version.workflow_state = CampaignVersionWorkflowState.FAILED.value
elif accepted:
campaign.status = CampaignStatus.SENT.value
if version:
version.workflow_state = CampaignVersionWorkflowState.COMPLETED.value
elif cancelled:
campaign.status = CampaignStatus.CANCELLED.value
if version:
version.workflow_state = CampaignVersionWorkflowState.CANCELLED.value
if version and (accepted or unknown):
if version.locked_at is None:
version.locked_at = _utcnow()
session.add(version)
session.add(campaign)
def _apply_campaign_delivery_status(
campaign: Campaign,
version: CampaignVersion | None,
counts: _CampaignDeliveryCounts,
) -> None:
status_pair = _campaign_delivery_status_pair(counts)
if status_pair is None:
return
campaign_status, workflow_state = status_pair
campaign.status = campaign_status
if version:
version.workflow_state = workflow_state
def _campaign_delivery_status_pair(counts: _CampaignDeliveryCounts) -> tuple[str, str] | None:
if counts.active:
return CampaignStatus.SENDING.value, CampaignVersionWorkflowState.SENDING.value
if counts.accepted and (counts.failed or counts.unknown or counts.cancelled or counts.not_started):
return CampaignStatus.PARTIALLY_COMPLETED.value, CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value
if counts.unknown:
return CampaignStatus.OUTCOME_UNKNOWN.value, CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value
if counts.failed:
return CampaignStatus.FAILED.value, CampaignVersionWorkflowState.FAILED.value
if counts.accepted:
return CampaignStatus.SENT.value, CampaignVersionWorkflowState.COMPLETED.value
if counts.cancelled:
return CampaignStatus.CANCELLED.value, CampaignVersionWorkflowState.CANCELLED.value
return None
def send_campaign_job(
@@ -984,15 +1112,49 @@ def send_campaign_job(
job = session.get(CampaignJob, job_id)
if not job:
raise SendJobError(f"Job not found: {job_id}")
preflight_result = _preflight_send_campaign_job(session, job, dry_run=dry_run)
if preflight_result is not None:
return preflight_result
context = _send_job_delivery_context(session, job)
if dry_run:
return SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
)
claimed = _claimed_campaign_job_for_delivery(session, job)
if isinstance(claimed, SendJobResult):
return claimed
claimed_job, claim_token = claimed
return _send_claimed_campaign_job(
session,
job=claimed_job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
def _preflight_send_campaign_job(
session: Session,
job: CampaignJob,
*,
dry_run: bool,
) -> SendJobResult | None:
if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value:
return SendJobResult(job_id=job_id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
return SendJobResult(job_id=job.id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
if job.queue_status == JobQueueStatus.PAUSED.value:
return SendJobResult(job_id=job_id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
return SendJobResult(job_id=job.id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status in SMTP_ACCEPTED_STATUSES:
return SendJobResult(job_id=job_id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run)
return SendJobResult(job_id=job.id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value:
return SendJobResult(
job_id=job_id,
job_id=job.id,
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=job.attempt_count,
dry_run=dry_run,
@@ -1014,7 +1176,10 @@ def send_campaign_job(
)
if job.queue_status != JobQueueStatus.QUEUED.value or job.send_status != JobSendStatus.QUEUED.value:
raise SendJobError(f"Job is not explicitly queued for delivery: queue={job.queue_status}, send={job.send_status}")
return None
def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDeliveryContext:
version = session.get(CampaignVersion, job.campaign_version_id)
if not version:
raise SendJobError("Campaign version not found")
@@ -1028,134 +1193,196 @@ def send_campaign_job(
envelope_recipients = _recipients_from_job(job)
if not envelope_recipients:
raise SmtpConfigurationError("No envelope recipients could be determined")
return _SendJobDeliveryContext(
version=version,
snapshot=snapshot,
message_bytes=message_bytes,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
if dry_run:
return SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(envelope_recipients)} recipient(s) from {envelope_from}",
)
def _claimed_campaign_job_for_delivery(
session: Session,
job: CampaignJob,
) -> tuple[CampaignJob, str] | SendJobResult:
claim_token = _claim_job_for_sending(session, job)
if claim_token is None:
current = session.get(CampaignJob, job.id)
if not current:
raise SendJobError(f"Job disappeared while claiming: {job.id}")
if current.send_status == JobSendStatus.SENDING.value:
return mark_job_outcome_unknown(
session,
current,
reason="A duplicate/redelivered task found an unfinished SMTP attempt. Automatic resend was stopped.",
)
return SendJobResult(
job_id=current.id,
status="not_claimed",
attempt_number=current.attempt_count,
message=f"Job is no longer queueable: {current.send_status}",
)
return _not_claimed_send_job_result(session, job)
job = session.get(CampaignJob, job.id)
assert job is not None
if job is None:
raise SendJobError("Claimed campaign job disappeared before send.")
return job, claim_token
def _not_claimed_send_job_result(session: Session, job: CampaignJob) -> SendJobResult:
current = session.get(CampaignJob, job.id)
if not current:
raise SendJobError(f"Job disappeared while claiming: {job.id}")
if current.send_status == JobSendStatus.SENDING.value:
return mark_job_outcome_unknown(
session,
current,
reason="A duplicate/redelivered task found an unfinished SMTP attempt. Automatic resend was stopped.",
)
return SendJobResult(
job_id=current.id,
status="not_claimed",
attempt_number=current.attempt_count,
message=f"Job is no longer queueable: {current.send_status}",
)
def _send_claimed_campaign_job(
session: Session,
*,
job: CampaignJob,
claim_token: str,
context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
mail_integration().wait_for_rate_limit(
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
messages_per_minute=snapshot.delivery.rate_limit.messages_per_minute,
messages_per_minute=context.snapshot.delivery.rate_limit.messages_per_minute,
enabled=use_rate_limit,
)
attempt = _record_attempt_start(session, job, claim_token)
try:
smtp_config = runtime_smtp_config(session, version, snapshot)
smtp_config = runtime_smtp_config(session, context.version, context.snapshot)
mail_integration().assert_mail_policy_allows_send(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
smtp=smtp_config,
envelope_sender=envelope_from,
from_header=_from_header_from_job(job, snapshot),
recipients=envelope_recipients,
envelope_sender=context.envelope_from,
from_header=_from_header_from_job(job, context.snapshot),
recipients=context.envelope_recipients,
)
result = mail_integration().send_email_bytes(
message_bytes,
context.message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
envelope_from=context.envelope_from,
envelope_recipients=context.envelope_recipients,
)
if result.accepted_count <= 0:
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
refused_warning = None
if result.refused_recipients:
refused_warning = (
f"SMTP accepted {result.accepted_count}/{len(result.envelope_recipients)} envelope recipient(s); "
f"refused recipients: {json.dumps(result.refused_recipients, default=str, sort_keys=True)}"
)
attempt.finished_at = _utcnow()
attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value
attempt.smtp_response = json.dumps(asdict(result), default=str)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.sent_at = _utcnow()
job.claim_token = None
job.outcome_unknown_at = None
if snapshot.delivery.imap_append_sent.enabled:
job.imap_status = JobImapStatus.PENDING.value
else:
job.imap_status = JobImapStatus.NOT_REQUESTED.value
job.last_error = refused_warning
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
if enqueue_imap_task and _celery_enabled() and job.imap_status == JobImapStatus.PENDING.value:
_celery_enqueue_append_sent_job(job.id)
return SendJobResult(
job_id=job.id,
status=JobSendStatus.SMTP_ACCEPTED.value,
attempt_number=attempt.attempt_number,
message=refused_warning,
return _record_smtp_send_success(
session,
job=job,
attempt=attempt,
snapshot=context.snapshot,
result=result,
enqueue_imap_task=enqueue_imap_task,
)
except SmtpSendError as exc:
if getattr(exc, "outcome_unknown", False):
attempt.status = JobSendStatus.OUTCOME_UNKNOWN.value
attempt.finished_at = _utcnow()
attempt.error_type = exc.__class__.__name__
attempt.error_message = str(exc)
session.add(attempt)
job.claim_token = None
session.add(job)
session.flush()
return mark_job_outcome_unknown(session, job, reason=str(exc))
attempt.finished_at = _utcnow()
attempt.error_type = exc.__class__.__name__
attempt.error_message = str(exc)
retryable = bool(getattr(exc, "temporary", False))
job.last_error = str(exc)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.FAILED_TEMPORARY.value if retryable else JobSendStatus.FAILED_PERMANENT.value
job.claim_token = None
attempt.status = job.send_status
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
outcome_unknown = _record_smtp_send_error(session, job=job, attempt=attempt, exc=exc)
if outcome_unknown is not None:
return outcome_unknown
raise
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
_record_permanent_send_error(session, job=job, attempt=attempt, exc=exc)
raise
def _record_smtp_send_success(
session: Session,
*,
job: CampaignJob,
attempt: SendAttempt,
snapshot: ExecutionSnapshot,
result: object,
enqueue_imap_task: bool,
) -> SendJobResult:
if result.accepted_count <= 0:
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
refused_warning = _refused_recipient_warning(result)
attempt.finished_at = _utcnow()
attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value
attempt.smtp_response = json.dumps(asdict(result), default=str)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.sent_at = _utcnow()
job.claim_token = None
job.outcome_unknown_at = None
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
job.last_error = refused_warning
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
if enqueue_imap_task and _celery_enabled() and job.imap_status == JobImapStatus.PENDING.value:
_celery_enqueue_append_sent_job(job.id)
return SendJobResult(
job_id=job.id,
status=JobSendStatus.SMTP_ACCEPTED.value,
attempt_number=attempt.attempt_number,
message=refused_warning,
)
def _refused_recipient_warning(result: object) -> str | None:
if not result.refused_recipients:
return None
return (
f"SMTP accepted {result.accepted_count}/{len(result.envelope_recipients)} envelope recipient(s); "
f"refused recipients: {json.dumps(result.refused_recipients, default=str, sort_keys=True)}"
)
def _record_smtp_send_error(
session: Session,
*,
job: CampaignJob,
attempt: SendAttempt,
exc: SmtpSendError,
) -> SendJobResult | None:
if getattr(exc, "outcome_unknown", False):
attempt.status = JobSendStatus.OUTCOME_UNKNOWN.value
attempt.finished_at = _utcnow()
attempt.error_type = exc.__class__.__name__
attempt.error_message = str(exc)
attempt.status = JobSendStatus.FAILED_PERMANENT.value
job.last_error = str(exc)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.FAILED_PERMANENT.value
job.claim_token = None
session.add(attempt)
job.claim_token = None
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
raise
session.flush()
return mark_job_outcome_unknown(session, job, reason=str(exc))
attempt.finished_at = _utcnow()
attempt.error_type = exc.__class__.__name__
attempt.error_message = str(exc)
retryable = bool(getattr(exc, "temporary", False))
job.last_error = str(exc)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.FAILED_TEMPORARY.value if retryable else JobSendStatus.FAILED_PERMANENT.value
job.claim_token = None
attempt.status = job.send_status
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
return None
def _record_permanent_send_error(
session: Session,
*,
job: CampaignJob,
attempt: SendAttempt,
exc: Exception,
) -> None:
attempt.finished_at = _utcnow()
attempt.error_type = exc.__class__.__name__
attempt.error_message = str(exc)
attempt.status = JobSendStatus.FAILED_PERMANENT.value
job.last_error = str(exc)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.FAILED_PERMANENT.value
job.claim_token = None
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
session.commit()
def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt:

View File

@@ -0,0 +1,151 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from govoplan_campaign.backend import router
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial
class CampaignPartialValidationTests(unittest.TestCase):
def test_partial_validation_reports_counts_across_sections(self) -> None:
result = validate_campaign_partial({
"campaign": {},
"recipients": {"from": {}},
"entries": {"source": {}, "mapping": {}},
"template": {"body_mode": "both"},
"attachments": {},
"delivery": {"rate_limit": {"messages_per_minute": 0}},
})
codes = {item["code"] for item in result["issues"]}
self.assertFalse(result["ok"])
self.assertEqual(3, result["error_count"])
self.assertIn("missing_campaign_id", codes)
self.assertIn("missing_email_mapping", codes)
self.assertIn("missing_template_text_body", codes)
self.assertIn("missing_attachment_base_path", codes)
self.assertIn("invalid_rate_limit", codes)
def test_partial_validation_filters_to_requested_section(self) -> None:
result = validate_campaign_partial(
{
"campaign": {},
"template": {"body_mode": "text"},
"delivery": {"rate_limit": {"messages_per_minute": "fast"}},
},
section="template",
)
self.assertTrue(result["ok"])
self.assertEqual("template", result["section"])
self.assertEqual(0, result["error_count"])
self.assertEqual({"missing_subject", "missing_template_text_body"}, {item["code"] for item in result["issues"]})
def test_address_lookup_returns_unavailable_when_addresses_capability_absent(self) -> None:
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(router, "_registry_capability", return_value=None):
response = router.lookup_campaign_addresses("campaign-1", query="", session=object(), principal=object())
self.assertFalse(response.available)
self.assertEqual(response.candidates, [])
def test_address_lookup_accepts_empty_query_for_suggestion_preload(self) -> None:
class LookupCapability:
def lookup(self, _session, _principal, *, query: str, limit: int):
self.query = query
self.limit = limit
return [{
"contact_id": "contact-1",
"address_book_id": "book-1",
"display_name": "Ada Lovelace",
"email": "ada@example.local",
"email_label": "work",
"tags": [],
"source_kind": "local",
"provenance": {"module": "addresses"},
}]
capability = LookupCapability()
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(router, "_registry_capability", return_value=capability):
response = router.lookup_campaign_addresses("campaign-1", query="", limit=100, session=object(), principal=object())
self.assertEqual(capability.query, "")
self.assertEqual(capability.limit, 100)
self.assertTrue(response.available)
self.assertEqual(response.candidates[0].email, "ada@example.local")
class CampaignSemanticValidationTests(unittest.TestCase):
def test_semantic_validation_reports_zip_delivery_and_external_mapping_issues(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "recipients.csv").write_text("email\nada@example.local\n", encoding="utf-8")
campaign_file = root / "campaign.json"
config = CampaignConfig.model_validate({
"version": "1.0",
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
"fields": [
{"name": "pin", "type": "string", "can_override": False},
{"name": "secret", "type": "password"},
],
"global_values": {"unknown": "value"},
"recipients": {
"from": [{"email": "sender@example.local"}],
"to": [{"email": "recipient@example.local"}],
},
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
"attachments": {
"base_path": "attachments",
"zip": {
"enabled": True,
"archives": [
{
"id": "main",
"name": "bundle.zip",
"standard": True,
"password_enabled": True,
"password_field": "secret",
"password_scope": "global",
}
],
},
},
"entries": {
"source": {"type": "csv", "path": "recipients.csv", "has_header": True},
"mapping": {"fields.pin": "missing_pin", "fields.unknown": "missing_unknown"},
"defaults": {
"attachments": [
{
"base_dir": "",
"file_filter": "*.pdf",
"zip": {"archive_id": "missing-archive"},
}
]
},
},
"delivery": {"imap_append_sent": {"enabled": True}},
})
report = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
codes = {issue.code for issue in report.issues}
self.assertFalse(report.ok)
self.assertEqual("external:csv", report.entries_mode)
self.assertIsNone(report.entries_count)
self.assertIn("unknown_global_value", codes)
self.assertIn("zip_global_password_value_missing", codes)
self.assertIn("zip_archive_unknown", codes)
self.assertIn("delivery_imap_enabled_without_server_imap", codes)
self.assertIn("missing_smtp_config", codes)
self.assertIn("unknown_mapping_target", codes)
self.assertIn("mapping_target_not_overridable", codes)
self.assertIn("mapping_columns_missing", codes)
self.assertIn("attachments_base_path_not_found", codes)
if __name__ == "__main__":
unittest.main()

View File

@@ -162,6 +162,59 @@ export type RecipientImportMappingProfileListResponse = {
profiles: RecipientImportMappingProfile[];
};
export type CampaignAddressLookupCandidate = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
email_label?: string | null;
organization?: string | null;
role_title?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
};
export type CampaignAddressLookupResponse = {
available: boolean;
candidates: CampaignAddressLookupCandidate[];
};
export type CampaignRecipientAddressSource = {
source_id: string;
source_label: string;
source_kind: string;
source_revision: string;
recipient_count: number;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourcesResponse = {
available: boolean;
sources: CampaignRecipientAddressSource[];
};
export type CampaignRecipientSnapshotItem = {
contact_id: string;
display_name: string;
email: string;
email_label?: string | null;
fields: Record<string, unknown>;
provenance: Record<string, unknown>;
};
export type CampaignRecipientAddressSourceSnapshot = {
source_id: string;
source_label: string;
source_kind: string;
source_revision: string;
generated_at: string;
recipients: CampaignRecipientSnapshotItem[];
provenance: Record<string, unknown>;
};
export type CampaignVersionUpdatePayload = {
campaign_json?: Record<string, unknown> | null;
current_flow?: string | null;
@@ -419,6 +472,34 @@ export async function deleteRecipientImportMappingProfile(settings: ApiSettings,
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export async function lookupCampaignAddresses(
settings: ApiSettings,
campaignId: string,
query: string,
limit = 25)
: Promise<CampaignAddressLookupResponse> {
const params = new URLSearchParams({ query, limit: String(limit) });
return apiFetch<CampaignAddressLookupResponse>(settings, `/api/v1/campaigns/${campaignId}/address-lookup?${params.toString()}`);
}
export async function listCampaignRecipientAddressSources(
settings: ApiSettings,
campaignId: string)
: Promise<CampaignRecipientAddressSourcesResponse> {
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
}
export async function snapshotCampaignRecipientAddressSource(
settings: ApiSettings,
campaignId: string,
sourceId: string)
: Promise<CampaignRecipientAddressSourceSnapshot> {
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
method: "POST",
body: JSON.stringify({ source_id: sourceId })
});
}
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
}

View File

@@ -1 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";
export { apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "@govoplan/core-webui";

View File

@@ -1,142 +1,55 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
import type {
ApiSettings,
MailConnectionTestResponse,
MailImapFolderListResponse,
MailImapTestPayload,
MailProfilePolicy,
MailProfilePolicyResponse,
MailProfileScope,
MailSecurity,
MailServerProfile,
MailServerProfilePayload,
MailSmtpTestPayload,
MockMailboxMessageResponse
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
export type MailSecurity = "plain" | "tls" | "starttls";
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
const profileActionEndpoints = {
smtp: "test-smtp",
imap: "test-imap",
folders: "list-imap-folders"
} as const;
export type MailSmtpTestPayload = {
host?: string | null;
port?: number | null;
username?: string | null;
password?: string | null;
security?: MailSecurity;
timeout_seconds?: number;
};
const rawSettingsEndpoints = {
smtp: "/api/v1/mail/test-smtp",
imap: "/api/v1/mail/test-imap",
folders: "/api/v1/mail/list-imap-folders"
} as const;
export type MailImapTestPayload = MailSmtpTestPayload & {
sent_folder?: string | null;
};
function runProfileAction<TResponse>(
settings: ApiSettings,
profileId: string,
action: keyof typeof profileActionEndpoints
): Promise<TResponse> {
return apiPost<TResponse>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
);
}
export type MailTransportCredentialsPayload = {
username?: string | null;
password?: string | null;
};
export type MailServerProfileCredentialsPayload = {
smtp?: MailTransportCredentialsPayload;
imap?: MailTransportCredentialsPayload;
};
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
details?: Record<string, unknown>;
};
export type MailServerProfile = {
id: string;
tenant_id?: string | null;
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
updated_at: string;
};
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const;
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: boolean | null;
};
export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null;
allow_user_profiles?: boolean | null;
allow_group_profiles?: boolean | null;
allow_campaign_profiles?: boolean | null;
smtp_credentials?: MailCredentialPolicy | null;
imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null;
};
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
function runRawSettingsAction<TResponse, TPayload>(
settings: ApiSettings,
payload: TPayload,
action: keyof typeof rawSettingsEndpoints
): Promise<TResponse> {
return apiPostJson<TResponse>(settings, rawSettingsEndpoints[action], payload);
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams();
if (includeInactive) params.set("include_inactive", "true");
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
return response.profiles ?? [];
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
include_inactive: includeInactive ? true : undefined,
campaign_id: campaignId
});
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
@@ -152,71 +65,40 @@ export async function getMailProfilePolicy(
scopeId?: string | null,
campaignId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
scope_id: scopeId,
campaign_id: campaignId
}));
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
}
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
method: "POST",
body: JSON.stringify(payload)
});
return runRawSettingsAction<MailConnectionTestResponse, MailSmtpTestPayload>(settings, payload, "smtp");
}
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
method: "POST",
body: JSON.stringify(payload)
});
return runRawSettingsAction<MailConnectionTestResponse, MailImapTestPayload>(settings, payload, "imap");
}
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
method: "POST",
body: JSON.stringify(payload)
});
return runRawSettingsAction<MailImapFolderListResponse, MailImapTestPayload>(settings, payload, "folders");
}
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
export type { MailConnectionTestResponse, MailCredentialPolicy, MailImapFolderListResponse, MailImapFolderResponse, MailImapTestPayload, MailProfilePatternKey, MailProfilePatternRules, MailProfilePolicy, MailProfilePolicyLimitKey, MailProfilePolicyLimitPermissions, MailProfilePolicyResponse, MailProfileScope, MailSecurity, MailServerProfile, MailServerProfileCredentialsPayload, MailServerProfileListResponse, MailServerProfilePayload, MailSmtpTestPayload, MailTransportCredentialsPayload, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";

View File

@@ -1,97 +0,0 @@
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
const personalContacts = [
{ name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" },
{ name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }];
const groupContacts = [
{ name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" },
{ name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }];
const tenantContacts = [
{ name: "i18n:govoplan-campaign.helpdesk.191815bf", email: "helpdesk@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" },
{ name: "i18n:govoplan-campaign.data_protection.06e87cd3", email: "privacy@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" }];
function contactColumns(): DataGridColumn<{name: string;email: string;source: string;tags: string;}>[] {
return [
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
{ id: "email", header: "i18n:govoplan-campaign.email.84add5b2", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
{ id: "scope", header: "i18n:govoplan-campaign.scope.4651a34e", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "i18n:govoplan-campaign.personal.40f07323" }, { value: "Group", label: "i18n:govoplan-campaign.group.171a0606" }, { value: "Tenant", label: "i18n:govoplan-campaign.tenant.3ca93c78" }] }, value: (contact) => contact.source },
{ id: "tags", header: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "i18n:govoplan-campaign.mock.3bba2a47" }], 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>i18n:govoplan-campaign.address_book.f6327f59</PageTitle>
<p>i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4</p>
</div>
<div className="button-row compact-actions">
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
<Button variant="primary" disabled>i18n:govoplan-campaign.add_contact.6da0b4b8</Button>
</div>
</div>
<div className="metric-grid">
<Card title="i18n:govoplan-campaign.personal.40f07323"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556</p></Card>
<Card title="i18n:govoplan-campaign.group.171a0606"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d</p></Card>
<Card title="i18n:govoplan-campaign.tenant.3ca93c78"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b</p></Card>
<Card title="i18n:govoplan-campaign.sync.905f6309"><strong className="module-big-number">i18n:govoplan-campaign.mock.3bba2a47</strong><p className="muted">i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513</p></Card>
</div>
<div className="dashboard-grid settings-dashboard-grid">
<Card title="i18n:govoplan-campaign.address_book_scopes.b0d0efde">
<div className="address-book-scope-list">
<AddressBookScope title="i18n:govoplan-campaign.personal_address_book.e240066d" description="i18n:govoplan-campaign.private_contacts_remembered_recipients_and_perso.685cca95" status="Local" />
<AddressBookScope title="i18n:govoplan-campaign.group_address_books.aed5a7c0" description="i18n:govoplan-campaign.shared_contact_sets_for_teams_departments_or_cam.4408edd7" status="Shared" />
<AddressBookScope title="i18n:govoplan-campaign.tenant_directory.11b0e09c" description="i18n:govoplan-campaign.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9" status="Directory" />
</div>
</Card>
<Card title="i18n:govoplan-campaign.planned_address_actions.1d4a056a">
<div className="placeholder-stack">
<span>i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4</span>
<span>i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529</span>
<span>i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464</span>
<span>i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a</span>
</div>
</Card>
</div>
<Card title="i18n:govoplan-campaign.contacts.b0dd615c" actions={<Button disabled>i18n:govoplan-campaign.manage_sources.ec758de0</Button>}>
<DataGrid
id="address-book-contacts"
rows={allContacts}
columns={contactColumns()}
getRowKey={(contact) => contact.source + "-" + contact.email}
emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09"
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

@@ -1,24 +1,28 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import {
createRecipientImportMappingProfile,
listCampaignRecipientAddressSources,
listRecipientImportMappingProfiles,
lookupCampaignAddresses,
snapshotCampaignRecipientAddressSource,
updateRecipientImportMappingProfile,
type CampaignAddressLookupCandidate,
type CampaignRecipientAddressSource,
type CampaignRecipientAddressSourceSnapshot,
type RecipientImportMappingProfilePayload } from
"../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FileDropZone } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
import { ToggleSwitch } from "@govoplan/core-webui";
import { EmailAddressInput } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { SegmentedControl } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
@@ -34,17 +38,18 @@ import {
createRecipientImportProvenance,
createRecipientMappingProfile as buildRecipientMappingProfile,
defaultColumnMappings,
importedRowsNeedAttachmentSource,
matchRecipientMappingProfiles,
materializeRecipientImport,
materializeRecipientImportWithAttachmentDefaults,
recipientImportHeaderFingerprints,
xlsxSheetsFromArrayBuffer,
type CsvDelimiter,
type ImportedAddress,
type ImportedRecipientRow,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview,
type RecipientImportTable,
type RecipientImportProvenance,
type RecipientImportSourceType,
type RecipientImportSpreadsheetSheet,
@@ -61,9 +66,10 @@ import {
import {
addressesFromValue,
collectCampaignAddressSuggestions,
dedupeAddresses,
type MailboxAddress } from
"@govoplan/core-webui";
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
const recipientHeaderRows = [
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" },
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" },
@@ -71,6 +77,7 @@ const recipientHeaderRows = [
const;
type RecipientAddressKey = "to" | "cc" | "bcc";
type AddressSourceFilter = "all" | "books" | "lists";
type EntryAddressColumn = {
key: RecipientAddressKey | "from" | "reply_to";
@@ -82,8 +89,17 @@ type EntryAddressColumn = {
};
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { translateText } = usePlatformLanguage();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
const [addressSourceImportInitialId, setAddressSourceImportInitialId] = useState("");
const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false);
const [addressSourcesLoading, setAddressSourcesLoading] = useState(false);
const [addressSources, setAddressSources] = useState<CampaignRecipientAddressSource[]>([]);
const [addressLookupQuery, setAddressLookupQuery] = useState("");
const [addressLookupSuggestions, setAddressLookupSuggestions] = useState<MailboxAddress[]>([]);
const [addressLookupCache, setAddressLookupCache] = useState<Record<string, MailboxAddress[]>>({});
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
@@ -110,6 +126,35 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]);
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const cachedAddressSuggestions = useMemo(
() => dedupeAddresses(Object.values(addressLookupCache).flat()),
[addressLookupCache]
);
const combinedAddressSuggestions = useMemo(
() => dedupeAddresses([...addressSuggestions, ...cachedAddressSuggestions, ...addressLookupSuggestions]),
[addressLookupSuggestions, addressSuggestions, cachedAddressSuggestions]
);
const addressSourceRevisionById = useMemo(
() => new Map(addressSources.map((addressSource) => [addressSource.source_id, addressSource.source_revision])),
[addressSources]
);
const staleAddressImports = useMemo(() => {
return asArray(entries.imports).
map(asRecord).
filter((record) => record.source_type === "addresses").
map((record) => {
const sourceId = typeof record.source_id === "string" ? record.source_id : "";
const importedRevision = typeof record.source_revision === "string" ? record.source_revision : "";
const currentRevision = sourceId ? addressSourceRevisionById.get(sourceId) ?? "" : "";
return {
sourceId,
sourceLabel: typeof record.source_label === "string" ? record.source_label : sourceId,
importedRevision,
currentRevision
};
}).
filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision);
}, [addressSourceRevisionById, entries.imports]);
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
@@ -133,6 +178,75 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
return columns;
}, [recipientsSection]);
useEffect(() => {
let cancelled = false;
setAddressSourcesLoading(true);
void listCampaignRecipientAddressSources(settings, campaignId).
then((response) => {
if (cancelled) return;
setAddressSourcesAvailable(response.available);
setAddressSources(response.available ? response.sources ?? [] : []);
}).
catch(() => {
if (cancelled) return;
setAddressSourcesAvailable(false);
setAddressSources([]);
}).
finally(() => {
if (!cancelled) setAddressSourcesLoading(false);
});
return () => {cancelled = true;};
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
const query = addressLookupQuery.trim();
if (query.length < 2) {
setAddressLookupSuggestions(addressLookupCache[""] ?? []);
return;
}
const cached = addressLookupCache[query.toLowerCase()];
if (cached) {
setAddressLookupSuggestions(cached);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
void lookupCampaignAddresses(settings, campaignId, query).
then((response) => {
if (cancelled) return;
if (!response.available) {
setAddressLookupSuggestions([]);
return;
}
const nextSuggestions = mailboxAddressesFromLookupCandidates(response.candidates);
setAddressLookupCache((current) => ({ ...current, [query.toLowerCase()]: nextSuggestions }));
setAddressLookupSuggestions(nextSuggestions);
}).
catch(() => {
if (!cancelled) setAddressLookupSuggestions([]);
});
}, 100);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [addressLookupCache, addressLookupQuery, campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
let cancelled = false;
void lookupCampaignAddresses(settings, campaignId, "", 100).
then((response) => {
if (cancelled || !response.available) return;
setAddressLookupCache((current) => ({ ...current, "": mailboxAddressesFromLookupCandidates(response.candidates) }));
}).
catch(() => undefined);
return () => {cancelled = true;};
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
const handleAddressSuggestionQueryChange = useCallback((query: string) => {
setAddressLookupQuery(query);
}, []);
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
@@ -200,63 +314,47 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: AttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath, provenance });
const nextDraft = needsAttachmentSource ?
{
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
} :
importedDraft;
setDraft(nextDraft);
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
markDirty();
setImportOpen(false);
}
function applyAddressSourceImport(snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) {
if (locked || !draft) return;
const preview = buildAddressSourceImportPreview(snapshot, inlineEntries);
const provenance = createAddressSourceImportProvenance(snapshot, preview, mode);
applyRecipientImport(preview, mode, provenance);
setAddressSourceImportOpen(false);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.sender_recipients.922c6d24</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
<>
<CampaignDraftPageScaffold
settings={settings}
campaignId={campaignId}
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
loading={loading}
version={version}
versions={data.versions}
saveState={saveState}
onReload={reload}
onSave={() => saveDraft("manual")}
dirty={dirty}
locked={locked}
draft={draft}
error={error}
localError={localError}
currentVersionId={data.campaign?.current_version_id}>
{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="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<Card title="i18n:govoplan-campaign.campaign_sender.b12e8ae2" collapsible>
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
<EmailAddressInput
value={defaultFrom}
suggestions={addressSuggestions}
suggestions={combinedAddressSuggestions}
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
allowMultiple={false}
disabled={locked}
addLabel="i18n:govoplan-campaign.set_from.11fe7396"
@@ -278,7 +376,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
<FormField label="i18n:govoplan-campaign.global_reply_to_address.bec1a89b">
<EmailAddressInput
value={globalReplyTo}
suggestions={addressSuggestions}
suggestions={combinedAddressSuggestions}
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
allowMultiple
disabled={locked}
addLabel="i18n:govoplan-campaign.add_reply_to.84b195f4"
@@ -305,7 +404,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
<FormField label={row.label}>
<EmailAddressInput
value={globalRecipientValues[row.key] ?? []}
suggestions={addressSuggestions}
suggestions={combinedAddressSuggestions}
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
allowMultiple
disabled={locked}
addLabel={row.addLabel}
@@ -326,16 +426,44 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
</div>
</Card>
<Card title="i18n:govoplan-campaign.recipient_profiles.3dc9671c" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
<Card
title="i18n:govoplan-campaign.recipient_profiles.3dc9671c"
actions={
<div className="button-row compact-actions">
{(addressSourcesAvailable || addressSourcesLoading) &&
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
Import address book/list
</Button>
}
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
</div>
}>
{inlineEntries.length === 0 && Boolean(source.type) &&
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4</DismissibleAlert>
}
{staleAddressImports.length > 0 &&
<DismissibleAlert tone="warning" dismissible={false}>
<div className="stale-address-import-warning">
<span>Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}.</span>
<div className="button-row compact-actions">
{staleAddressImports.map((item) =>
<Button
key={item.sourceId}
type="button"
onClick={() => {setAddressSourceImportInitialId(item.sourceId);setAddressSourceImportOpen(true);}}>
Re-import {item.sourceLabel || "source"}
</Button>
)}
</div>
</div>
</DismissibleAlert>
}
{!source.type &&
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions: combinedAddressSuggestions, onAddressSuggestionQueryChange: handleAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026"
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />}
@@ -354,9 +482,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
</div>
}
</Card>
</>
</LoadingFrame>
</CampaignDraftPageScaffold>
{importOpen &&
<RecipientImportDialog
settings={settings}
@@ -368,8 +494,263 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
onImport={applyRecipientImport} />
}
</div>);
{addressSourceImportOpen &&
<AddressSourceImportDialog
settings={settings}
campaignId={campaignId}
sources={addressSources}
initialSourceId={addressSourceImportInitialId}
onCancel={() => setAddressSourceImportOpen(false)}
onImport={applyAddressSourceImport} />
}
</>);
}
type AddressSourceImportDialogProps = {
settings: ApiSettings;
campaignId: string;
sources: CampaignRecipientAddressSource[];
initialSourceId?: string;
onCancel: () => void;
onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void;
};
function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" {
return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book";
}
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
if (addressSourceType(source) === "list") return "Address list";
if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`;
return "Address book";
}
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
const scopeType = String(provenance.scope_type ?? "").trim();
const scopeId = String(provenance.scope_id ?? "").trim();
if (!scopeType) return "Unknown scope";
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
}
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
return [
source.source_label,
source.source_kind,
source.source_id,
addressSourceTypeLabel(source),
addressSourceScopeLabel(source),
provenance.address_book_id,
provenance.address_list_id,
provenance.scope_type,
provenance.scope_id,
provenance.tenant_id
].map((value) => String(value ?? "").toLowerCase()).join(" ");
}
function shortSourceRevision(value: string): string {
if (!value) return "no revision";
if (value.length <= 24) return value;
return `${value.slice(0, 19)}...`;
}
function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) {
const navigate = useGuardedNavigate();
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || "");
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
const [sourceQuery, setSourceQuery] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const sourceCounts = useMemo(() => {
return sources.reduce(
(counts, source) => {
counts[addressSourceType(source)] += 1;
return counts;
},
{ book: 0, list: 0 }
);
}, [sources]);
const filteredSources = useMemo(() => {
const query = sourceQuery.trim().toLowerCase();
return sources.filter((source) => {
const type = addressSourceType(source);
if (sourceFilter === "books" && type !== "book") return false;
if (sourceFilter === "lists" && type !== "list") return false;
if (!query) return true;
return addressSourceSearchText(source).includes(query);
});
}, [sourceFilter, sourceQuery, sources]);
const selectedSource = useMemo(
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
[selectedSourceId, sources]
);
useEffect(() => {
if (filteredSources.some((source) => source.source_id === selectedSourceId)) return;
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
}, [filteredSources, selectedSourceId]);
useEffect(() => {
if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) {
setSourceFilter("all");
setSourceQuery("");
setSelectedSourceId(initialSourceId);
}
}, [initialSourceId, sources]);
useEffect(() => {
if (!selectedSourceId) {
setSnapshot(null);
return;
}
let cancelled = false;
setLoading(true);
setError("");
void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId).
then((nextSnapshot) => {
if (!cancelled) setSnapshot(nextSnapshot);
}).
catch((err) => {
if (cancelled) return;
setSnapshot(null);
setError(err instanceof Error ? err.message : String(err));
}).
finally(() => {
if (!cancelled) setLoading(false);
});
return () => {cancelled = true;};
}, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function openAddressBookModule() {
onCancel();
navigate("/address-book");
}
return (
<Dialog
open
title="Import recipients from addresses"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={loading}
closeOnBackdrop={!loading}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
Import recipients
</Button>
</>
}>
<div className="address-source-import-controls">
<div>
<SegmentedControl
ariaLabel="Address source type"
value={sourceFilter}
onChange={setSourceFilter}
size="content"
width="inline"
disabled={loading || sources.length === 0}
options={[
{ id: "all", label: `All (${sources.length})` },
{ id: "books", label: `Books (${sourceCounts.book})` },
{ id: "lists", label: `Lists (${sourceCounts.list})` }
]}
/>
</div>
<input
type="search"
value={sourceQuery}
disabled={loading || sources.length === 0}
placeholder="Search address sources"
aria-label="Search address sources"
onChange={(event) => setSourceQuery(event.target.value)}
/>
<Button type="button" onClick={openAddressBookModule}>
Manage address books
</Button>
</div>
<div className="campaign-header-grid recipient-import-upload-grid">
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
{filteredSources.map((source) =>
<button
type="button"
key={source.source_id}
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
disabled={loading}
role="radio"
aria-checked={source.source_id === selectedSourceId}
onClick={() => setSelectedSourceId(source.source_id)}>
<span className="address-source-option-main">
<strong>{source.source_label}</strong>
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
</span>
<span className="address-source-option-meta">
<span>{source.recipient_count} recipients</span>
<code>{shortSourceRevision(source.source_revision)}</code>
</span>
</button>
)}
{sources.length > 0 && filteredSources.length === 0 &&
<div className="empty-state compact-empty">No address sources match the current filter.</div>
}
</div>
<FormField label="Import mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
</select>
</FormField>
</div>
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
{snapshot &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Fields</th>
</tr>
</thead>
<tbody>
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
<tr key={`${recipient.contact_id}-${recipient.email}`}>
<td>{index + 1}</td>
<td>{recipient.display_name}</td>
<td>{recipient.email}</td>
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
</tr>
)}
</tbody>
</table>
</div>
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
</>
}
</Dialog>);
}
type RecipientImportDialogProps = {
@@ -1188,18 +1569,162 @@ function formatImportBytes(value?: number | null): string {
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
const tableRows = [
headers,
...snapshot.recipients.map((recipient) => headers.map((header) => {
if (header === "display_name") return recipient.display_name;
if (header === "email") return recipient.email;
return fieldValueToString((recipient.fields ?? {})[header]);
}))];
const table: RecipientImportTable = {
delimiter: ",",
headers,
headerRows: [headers],
rows: tableRows,
dataRows: tableRows.slice(1),
firstDataRowNumber: 2
};
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
const fieldNamesToCreate = new Set<string>();
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
const email = recipient.email.trim();
const name = recipient.display_name.trim();
const fields = stringFieldsFromAddressSource(recipient.fields);
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
const id = uniqueImportId(
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
usedIds
);
const issues: string[] = [];
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
return {
rowNumber: table.firstDataRowNumber + index,
id,
name,
email,
active: true,
addresses: {
from: [],
to: email ? [{ name, email }] : [],
cc: [],
bcc: [],
reply_to: []
},
fields,
patterns: [],
issues
};
});
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: 0
};
}
function createAddressSourceImportProvenance(
snapshot: CampaignRecipientAddressSourceSnapshot,
preview: RecipientImportPreview,
mode: RecipientImportMode)
: RecipientImportProvenance {
const now = new Date().toISOString();
return {
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
imported_at: now,
mode,
source_type: "addresses",
source_id: snapshot.source_id,
source_label: snapshot.source_label,
source_revision: snapshot.source_revision,
source_provenance: snapshot.provenance,
filename: null,
sheet_name: null,
encoding: null,
delimiter: null,
header_rows: 0,
quoted: null,
value_separators: null,
rows_total: preview.rows.length,
valid_rows: preview.validCount,
invalid_rows: preview.invalidCount,
imported_rows: preview.validCount,
field_names_created: preview.fieldNamesToCreate.slice(),
attachment_patterns: 0,
mapping: []
};
}
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
const fields: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value ?? {})) {
const fieldValue = fieldValueToString(rawValue);
if (fieldValue) fields[key] = fieldValue;
}
return fields;
}
function fieldValueToString(value: unknown): string {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value).trim();
}
function idFragmentFromEmail(value: string): string {
return safeImportIdFragment(value.split("@")[0] ?? "");
}
function safeImportIdFragment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
}
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
const base = safeImportIdFragment(preferred) || "recipient";
let candidate = base;
let counter = 2;
while (usedIds.has(candidate)) {
candidate = `${base}-${counter}`;
counter += 1;
}
usedIds.add(candidate);
return candidate;
}
function suggestImportFieldName(value: string): string {
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
return cleaned || "field";
}
function mailboxAddressesFromLookupCandidates(candidates: CampaignAddressLookupCandidate[]): MailboxAddress[] {
return dedupeAddresses(
candidates.
map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null).
filter((address): address is MailboxAddress => Boolean(address?.email))
);
}
type RecipientProfileColumnContext = {
locked: boolean;
entries: Record<string, unknown>[];
entryDefaults: Record<string, unknown>;
entryAddressColumns: EntryAddressColumn[];
addressSuggestions: MailboxAddress[];
onAddressSuggestionQueryChange: (query: string) => void;
translateText: (value: string) => string;
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;
@@ -1208,7 +1733,7 @@ type RecipientProfileColumnContext = {
removeEntry: (index: number) => void;
};
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, onAddressSuggestionQueryChange, translateText, 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>> => ({
@@ -1222,6 +1747,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC
<EmailAddressInput
value={getEntryAddresses(entry, column.key)}
suggestions={addressSuggestions}
onSuggestionQueryChange={onAddressSuggestionQueryChange}
allowMultiple={column.allowMultiple}
compact
disabled={locked}
@@ -1237,7 +1763,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC
disabled={locked}
onChange={(checked) => updateEntryMerge(index, column.mergeKey!, checked)} />
<span className="muted">i18n:govoplan-campaign.merge_with_global.ba230098 {column.label}</span>
<span className="muted">{translateText("i18n:govoplan-campaign.merge_with_global.ba230098")} {translateText(column.label)}</span>
</div>
}
</div>,

View File

@@ -4,18 +4,15 @@ import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
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 AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { importedRowsNeedAttachmentSource, materializeRecipientImport, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { materializeRecipientImportWithAttachmentDefaults, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { RecipientImportDialog } from "./RecipientDataPage";
import { addressesFromValue } from "@govoplan/core-webui";
@@ -80,56 +77,31 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: AttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const nextDraft = needsAttachmentSource ?
{
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
} :
importedDraft;
setDraft(nextDraft);
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode }));
markDirty();
setImportOpen(false);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>i18n:govoplan-campaign.recipient_data.c2baaf10</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
</div>
</div>
<>
<CampaignDraftPageScaffold
settings={settings}
campaignId={campaignId}
title="i18n:govoplan-campaign.recipient_data.c2baaf10"
loading={loading}
version={version}
versions={data.versions}
saveState={saveState}
onReload={reload}
onSave={() => saveDraft("manual")}
dirty={dirty}
locked={locked}
draft={draft}
error={error}
localError={localError}
currentVersionId={data.campaign?.current_version_id}>
{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="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>
<Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
{inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
{inlineEntries.length === 0 && Boolean(source.type) &&
@@ -158,8 +130,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
</div>
}
</Card>
</>
</LoadingFrame>
</CampaignDraftPageScaffold>
{importOpen &&
<RecipientImportDialog
@@ -172,7 +143,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
onImport={applyRecipientImport} />
}
</div>);
</>);
}

View File

@@ -95,8 +95,8 @@ const stateColors: Record<FlowState, string> = {
active: "var(--blue)",
locked: "var(--line-dark)",
running: "var(--blue)",
partial: "#9b86c7",
stale: "#9b86c7",
partial: "var(--review-flow-purple)",
stale: "var(--review-flow-purple)",
pending: "var(--muted)"
};

View File

@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { Button, DismissibleAlert, LoadingFrame, PageTitle } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
import LockedVersionNotice from "./LockedVersionNotice";
import VersionLine from "./VersionLine";
type CampaignDraftPageScaffoldProps = {
settings: ApiSettings;
campaignId: string;
title: string;
loading: boolean;
version: CampaignVersionDetail | null;
versions: CampaignVersionListItem[];
saveState: string;
onReload: () => Promise<void>;
onSave: () => void;
dirty: boolean;
locked: boolean;
draft: Record<string, unknown> | null;
error?: string | null;
localError?: string | null;
currentVersionId?: string | null;
children: ReactNode;
};
export default function CampaignDraftPageScaffold({
settings,
campaignId,
title,
loading,
version,
versions,
saveState,
onReload,
onSave,
dirty,
locked,
draft,
error,
localError,
currentVersionId,
children
}: CampaignDraftPageScaffoldProps) {
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>{title}</PageTitle>
<VersionLine version={version} versions={versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={onReload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
<Button variant="primary" onClick={onSave} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</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={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
<>{children}</>
</LoadingFrame>
</div>);
}

View File

@@ -4,7 +4,6 @@ type XlsxWorkbookSheet = {
data: unknown[][];
};
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
type RawXlsxWorkbookReader = (input: unknown) => Promise<XlsxWorkbookSheet[]>;
export type ImportFieldDefinition = {
name: string;
@@ -16,7 +15,11 @@ export type ImportFieldDefinition = {
export type ImportAttachmentBasePath = {
id?: string;
name?: string;
path?: string;
source?: string;
allow_individual?: boolean;
unsent_warning?: boolean;
};
export type RecipientImportMode = "append" | "replace";
@@ -29,7 +32,7 @@ export type CsvParseOptions = {
quoted: boolean;
};
export type RecipientImportSourceType = "csv" | "xlsx" | "text";
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses";
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
@@ -99,6 +102,10 @@ export type RecipientImportProvenance = {
imported_at: string;
mode: RecipientImportMode;
source_type: RecipientImportSourceType;
source_id?: string | null;
source_label?: string | null;
source_revision?: string | null;
source_provenance?: Record<string, unknown>;
filename?: string | null;
sheet_name?: string | null;
encoding?: string | null;
@@ -211,15 +218,6 @@ async function loadXlsxWorkbookReader(): Promise<XlsxWorkbookReader> {
const module = await import("read-excel-file/browser");
return module.default as XlsxWorkbookReader;
}
const globals = globalThis as typeof globalThis & {
Buffer?: { from(input: ArrayBuffer): unknown };
process?: { versions?: { node?: string } };
};
if (globals.process?.versions?.node && globals.Buffer?.from) {
const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise<{ default: RawXlsxWorkbookReader }>;
const module = await dynamicImport("read-excel-file/node");
return (input: ArrayBuffer) => module.default(globals.Buffer!.from(input));
}
const module = await import("read-excel-file/universal");
return module.default as XlsxWorkbookReader;
}
@@ -463,6 +461,37 @@ options: MaterializeImportOptions)
};
}
export function materializeRecipientImportWithAttachmentDefaults(
draft: Record<string, unknown>,
preview: RecipientImportPreview,
options: MaterializeImportOptions)
: Record<string, unknown> {
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeImportAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: ImportAttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = options.attachmentBasePath ?? nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { ...options, attachmentBasePath });
if (!needsAttachmentSource) return importedDraft;
return {
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
};
}
export function createRecipientImportProvenance(input: {
preview: RecipientImportPreview;
mappings: RecipientColumnMapping[];
@@ -513,6 +542,27 @@ export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
}
function normalizeImportAttachmentBasePaths(value: unknown, attachments: ImportRecord, fallbackAllowIndividual = false): ImportAttachmentBasePath[] {
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
}];
}
function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: ImportAttachmentBasePath | null): Record<string, unknown> {
return {
id: row.id,
@@ -815,6 +865,14 @@ function getText(record: ImportRecord, key: string, fallback = ""): string {
return fallback;
}
function getBool(record: ImportRecord, key: string, fallback = false): boolean {
const value = record[key];
if (typeof value === "boolean") return value;
if (typeof value === "string") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
if (typeof value === "number") return value !== 0;
return fallback;
}
function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}

View File

@@ -2,7 +2,6 @@ export { default } from "./module";
export * from "./module";
export * from "./api/campaigns";
export * from "./features/campaigns/policyUi";
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";

View File

@@ -5,7 +5,6 @@ import { getCampaign } from "./api/campaigns";
import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/campaign-workspace.css";
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
@@ -35,7 +34,6 @@ export const campaignModule: PlatformWebModule = {
order: 30
},
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
{ to: "/address-book", label: "i18n:govoplan-campaign.address_book.f6327f59", iconName: "users", order: 80 },
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
routes: [
@@ -43,7 +41,6 @@ export const campaignModule: PlatformWebModule = {
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
{ path: "/address-book", order: 80, render: () => createElement(AddressBookPage) },
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
};

View File

@@ -10,7 +10,7 @@
}
.wizard-action-card {
min-height: 176px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
padding: 16px;
@@ -37,7 +37,7 @@
.data-table code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #5d5a55;
color: var(--text-subtle);
white-space: pre-wrap;
}
@@ -49,7 +49,7 @@
white-space: pre-wrap;
color: var(--text);
background: var(--panel-soft);
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
padding: 14px;
font: inherit;
@@ -64,7 +64,7 @@
display: inline-flex;
align-items: center;
min-height: 28px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 999px;
background: var(--panel-soft);
padding: 4px 10px;
@@ -90,13 +90,13 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.45;
color: #4d4944;
color: var(--text);
}
.danger-text { color: var(--danger-text); }
.section-mini-heading {
margin: 18px 0 8px;
font-size: 12px;
color: #6b6863;
color: var(--text-label);
letter-spacing: .04em;
text-transform: uppercase;
}
@@ -140,7 +140,7 @@
.form-subsection {
display: grid;
gap: 12px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
padding-top: 14px;
}
.form-subsection:first-child {
@@ -169,7 +169,7 @@
grid-column: 1 / -1;
}
.toggle-span-full {
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: var(--panel-soft);
padding: 10px 12px;
@@ -240,7 +240,7 @@
gap: 6px;
min-height: 88px;
padding: 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 12px;
background: var(--panel-soft);
color: inherit;
@@ -272,7 +272,7 @@
display: grid;
gap: 10px;
padding: 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 12px;
background: var(--panel-soft);
}
@@ -314,10 +314,10 @@
z-index: 30;
min-height: 64px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid var(--line);
background: var(--surface);
border-bottom: var(--border-line);
border-radius: var(--radius) var(--radius) 0 0;
box-shadow: 0 14px 18px -22px rgba(45, 43, 40, .75);
box-shadow: var(--shadow-campaign-card);
}
.campaigns-page .card-body {
padding: 0;
@@ -449,7 +449,7 @@
}
.message-preview {
border: 1px solid var(--border);
border: var(--border-line);
border-radius: var(--radius-md);
background: var(--surface-subtle);
padding: 1rem;
@@ -484,10 +484,10 @@
}
.overview-config-card {
min-height: 218px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: linear-gradient(#ffffff, var(--panel-soft));
box-shadow: 0 1px 2px rgba(38, 35, 30, .04);
background: linear-gradient(var(--control-gradient-start), var(--panel-soft));
box-shadow: var(--shadow-campaign-soft);
padding: 16px;
display: grid;
grid-template-rows: 22px 44px 1fr 35px;
@@ -520,7 +520,7 @@
gap: 10px;
align-items: baseline;
min-height: 31px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
padding-top: 8px;
}
.overview-config-facts dt {
@@ -577,20 +577,20 @@
.field-chip-button:hover,
.field-chip-button:focus-visible {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,.09);
box-shadow: var(--shadow-campaign-small);
}
.field-chip-button.used {
background: var(--green-soft);
border-color: #78b7a9;
color: #2d685d;
border-color: var(--success-border-soft);
color: var(--success-text-strong);
}
.field-chip-button.undefined {
background: #fff1d0;
border-color: #d5a64a;
color: #7c5412;
background: var(--warning-muted-bg);
border-color: var(--warning-border);
color: var(--warning-deep);
}
.field-chip-namespace {
border-right: 1px solid rgba(0,0,0,.18);
border-right: var(--border-muted-line);
color: var(--muted);
margin-right: 2px;
padding-right: 6px;
@@ -614,9 +614,9 @@
.template-preview-frame {
width: 100%;
min-height: 360px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: #fff;
background: var(--surface);
}
.template-action-dialog {
width: min(620px, 100%);
@@ -749,7 +749,7 @@
display: inline-flex;
justify-content: center;
min-height: 24px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 999px;
background: var(--panel-soft);
color: var(--muted);
@@ -853,7 +853,7 @@
.attachment-file-browser-panel {
display: grid;
gap: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 12px;
background: var(--panel-soft);
padding: 14px;
@@ -908,7 +908,7 @@
.chooser-display-input:focus,
.field-with-action input.chooser-display-input:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(239, 107, 58, .16);
box-shadow: var(--accent-ring-soft);
}
.chooser-display-input:disabled,
@@ -948,7 +948,7 @@
align-items: center;
justify-content: center;
min-height: 24px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 999px;
background: var(--panel-soft);
color: var(--muted);
@@ -973,30 +973,6 @@
.recipient-data-table td:nth-child(3) { min-width: 192px; }
.recipient-index-cell { white-space: nowrap; }
.address-book-scope-list {
display: grid;
gap: 10px;
}
.address-book-scope-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel-soft);
padding: 14px;
}
.address-book-scope-card strong {
display: block;
color: var(--text-strong);
}
.address-book-scope-card p {
margin: 5px 0 0;
color: var(--muted);
}
.locked-version-notice .alert-message {
display: flex;
align-items: center;
@@ -1012,12 +988,12 @@
}
.locked-version-feedback {
color: var(--success, #157347);
color: var(--success);
font-weight: 600;
}
.locked-version-error {
color: var(--danger, #b42318);
color: var(--danger-border-deep);
font-weight: 600;
}
@@ -1045,7 +1021,7 @@
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
color: var(--text, #243247);
color: var(--text);
text-decoration: none;
line-height: 1;
font-size: 0.72rem;
@@ -1057,8 +1033,8 @@
}
.version-arrow:hover {
background: var(--surface-subtle, rgba(15, 23, 42, 0.08));
color: var(--text, #111827);
background: var(--surface-subtle);
color: var(--text);
}
.version-arrow.disabled {
@@ -1108,7 +1084,7 @@
overflow: auto;
border: 1px solid var(--line-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.62);
background: var(--panel-glass);
color: var(--text-primary);
padding: 12px;
white-space: pre-wrap;
@@ -1154,7 +1130,7 @@
max-width: 100%;
border: 1px solid var(--line-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.68);
background: var(--panel-glass);
padding: 0.55rem 0.7rem;
color: var(--text-primary);
}
@@ -1188,7 +1164,7 @@
margin: 0;
}
.warning-text {
color: var(--warning, #8a5b00);
color: var(--warning);
}
/* Managed attachment chooser refinements. */
@@ -1212,7 +1188,7 @@
}
/* Review & Send workflow page. */
.review-send-page {
--review-flow-purple: #9b86c7;
--review-flow-purple: var(--review-flow-purple);
}
.review-flow-navigation {
@@ -1221,10 +1197,10 @@
z-index: 35;
margin: 0 0 24px;
padding: 15px 10px 10px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius);
background: rgba(247, 246, 244, .96);
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
background: var(--campaign-card-bg);
box-shadow: 0 8px 24px var(--campaign-stage-shadow-color);
backdrop-filter: blur(12px);
overflow-x: auto;
}
@@ -1258,7 +1234,7 @@
.review-flow-navigation-item:hover .review-flow-navigation-icon,
.review-flow-navigation-item:focus-visible .review-flow-navigation-icon {
background: color-mix(in srgb, var(--review-nav-color) 15%, #fff);
background: color-mix(in srgb, var(--review-nav-color) 15%, var(--surface));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--review-nav-color) 12%, transparent);
}
@@ -1274,7 +1250,7 @@
border: 1px solid var(--review-nav-color);
border-radius: 50%;
color: color-mix(in srgb, var(--review-nav-color) 82%, var(--text));
background: color-mix(in srgb, var(--review-nav-color) 8%, #fff);
background: color-mix(in srgb, var(--review-nav-color) 8%, var(--surface));
transition: background .16s ease, box-shadow .16s ease;
}
@@ -1346,8 +1322,8 @@
border: 1px solid var(--review-stage-color);
border-radius: 50%;
color: color-mix(in srgb, var(--review-stage-color) 82%, var(--text));
background: color-mix(in srgb, var(--review-stage-color) 9%, #fff);
box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10);
background: color-mix(in srgb, var(--review-stage-color) 9%, var(--surface));
box-shadow: 0 0 0 4px var(--bg), 0 4px 12px var(--campaign-stage-shadow-color);
}
.review-flow-stage[data-state="running"] .review-flow-stage-node {
@@ -1402,8 +1378,8 @@
padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--review-badge-color) 70%, var(--line));
border-radius: var(--radius-pill);
color: color-mix(in srgb, var(--review-badge-color) 70%, #242424);
background: color-mix(in srgb, var(--review-badge-color) 18%, #fff);
color: color-mix(in srgb, var(--review-badge-color) 70%, var(--neutral-950));
background: color-mix(in srgb, var(--review-badge-color) 18%, var(--surface));
font-size: 11px;
font-weight: 800;
letter-spacing: .03em;
@@ -1447,7 +1423,7 @@
padding: 28px;
color: var(--text);
text-align: center;
background: rgba(247, 246, 244, .46);
background: var(--campaign-card-bg-soft);
}
.review-flow-lock-message::before {
@@ -1455,12 +1431,12 @@
inset: 50% auto auto 50%;
width: min(430px, calc(100% - 44px));
height: 110px;
border: 1px solid rgba(189, 184, 176, .8);
border: 1px solid var(--campaign-panel-border);
border-radius: 10px;
content: "";
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, .91);
box-shadow: 0 12px 32px rgba(48, 49, 53, .14);
background: var(--campaign-panel-bg);
box-shadow: 0 12px 32px var(--campaign-panel-shadow-color);
}
.review-flow-lock-message > * {
@@ -1484,10 +1460,10 @@
height: 34px;
display: grid;
place-items: center;
border: 1px solid var(--line-dark);
border: var(--border-line-dark);
border-radius: 50%;
color: var(--muted);
background: #fff;
background: var(--surface);
}
.review-flow-fact-grid,
@@ -1504,7 +1480,7 @@
gap: 5px;
min-width: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 6px;
background: var(--panel-soft);
}
@@ -1534,26 +1510,26 @@
margin: 0 0 14px;
padding: 10px 12px;
border-left: 3px solid var(--blue);
background: color-mix(in srgb, var(--blue) 10%, #fff);
background: color-mix(in srgb, var(--blue) 10%, var(--surface));
color: var(--text);
font-size: 13px;
}
.review-flow-inline-note.is-complete {
border-left-color: var(--green);
background: color-mix(in srgb, var(--green) 12%, #fff);
background: color-mix(in srgb, var(--green) 12%, var(--surface));
}
.review-flow-inline-note.is-warning {
border-left-color: var(--amber);
background: color-mix(in srgb, var(--amber) 16%, #fff);
background: color-mix(in srgb, var(--amber) 16%, var(--surface));
}
.review-flow-inline-note.is-danger {
border-left-color: var(--red);
background: color-mix(in srgb, var(--red) 10%, #fff);
background: color-mix(in srgb, var(--red) 10%, var(--surface));
}
.review-flow-inline-note.is-stale {
border-left-color: var(--review-flow-purple);
background: color-mix(in srgb, var(--review-flow-purple) 11%, #fff);
background: color-mix(in srgb, var(--review-flow-purple) 11%, var(--surface));
}
.review-flow-result-line {
@@ -1596,7 +1572,7 @@
gap: 12px;
min-width: 0;
padding: 12px 14px;
border: 1px solid var(--line);
border: var(--border-line);
border-left: 3px solid var(--blue);
border-radius: 6px;
background: var(--panel-soft);
@@ -1665,8 +1641,8 @@
}
@keyframes review-flow-pulse {
0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10); }
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px rgba(48, 49, 53, .10); }
0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px var(--campaign-stage-shadow-color); }
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px var(--campaign-stage-shadow-color); }
}
@media (max-width: 980px) {
@@ -1775,7 +1751,7 @@
.attachment-zip-name-button[aria-invalid="true"] {
border-color: var(--red);
box-shadow: 0 0 0 2px rgba(224, 106, 95, 0.16);
box-shadow: 0 0 0 2px var(--danger-focus-ring-soft);
}
.attachment-zip-name-button:disabled {
@@ -1797,7 +1773,7 @@
.template-expression-dialog-header {
padding: 16px 18px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
background: var(--panel);
}
@@ -1810,7 +1786,7 @@
.template-expression-dialog-footer {
padding: 12px 18px;
border-top: 1px solid var(--line);
border-top: var(--border-line);
background: var(--panel);
}
@@ -1835,7 +1811,7 @@
.template-expression-value-field input[aria-invalid="true"] {
border-color: var(--red);
box-shadow: 0 0 0 2px rgba(224, 106, 95, 0.16), inset 0 1px 2px rgba(0, 0, 0, .04);
box-shadow: var(--danger-focus-ring-soft), var(--shadow-control-inset);
}
.attachment-zip-name-error {
@@ -1872,7 +1848,7 @@
border: 2px solid var(--line-strong, var(--line-subtle));
border-radius: 14px;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.4);
background: var(--panel-glass);
}
.attachment-zip-group > header {
@@ -1905,6 +1881,11 @@
flex: 0 0 auto;
}
.stale-address-import-warning {
display: grid;
gap: 8px;
}
.recipient-profiles-section {
display: grid;
gap: 12px;
@@ -1943,9 +1924,9 @@
max-height: 280px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
background: var(--surface-muted, #f6f7f9);
background: var(--surface-muted);
white-space: pre-wrap;
word-break: break-word;
}
@@ -1969,7 +1950,7 @@
}
.recipient-outcome-error {
color: var(--danger, #b91c1c);
color: var(--danger-border-deep);
}
.attempt-history-section {
@@ -1983,7 +1964,7 @@
.attempt-history-wrap {
overflow: auto;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: 8px;
}
@@ -1996,7 +1977,7 @@
.attempt-history-table th,
.attempt-history-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
text-align: left;
vertical-align: top;
}
@@ -2022,9 +2003,9 @@
padding: 14px;
margin: 12px 0;
overflow-wrap: anywhere;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9);
background: var(--surface-muted);
font-size: 14px;
user-select: all;
}
@@ -2033,9 +2014,9 @@
max-height: 420px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface-muted, #f6f7f9);
background: var(--surface-muted);
white-space: pre-wrap;
word-break: break-word;
}
@@ -2055,10 +2036,10 @@
}
.recipient-import-steps {
padding: 13px 10px 9px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius);
background: rgba(247, 246, 244, .96);
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
background: var(--campaign-card-bg);
box-shadow: 0 8px 24px var(--campaign-stage-shadow-color);
overflow-x: auto;
}
.recipient-import-step-track {
@@ -2091,8 +2072,8 @@
}
.recipient-import-step-item:not(:disabled):hover .recipient-import-step-icon,
.recipient-import-step-item:focus-visible .recipient-import-step-icon {
background: color-mix(in srgb, var(--accent, #5d776c) 14%, #fff);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #5d776c) 12%, transparent);
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent);
}
.recipient-import-step-item:focus-visible {
outline: none;
@@ -2102,17 +2083,17 @@
height: 38px;
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent, #5d776c) 70%, var(--line));
border: 1px solid color-mix(in srgb, var(--accent) 70%, var(--line));
border-radius: 50%;
color: color-mix(in srgb, var(--accent, #5d776c) 82%, var(--text));
background: color-mix(in srgb, var(--accent, #5d776c) 8%, #fff);
color: color-mix(in srgb, var(--accent) 82%, var(--text));
background: color-mix(in srgb, var(--accent) 8%, var(--surface));
font-weight: 700;
transition: background .16s ease, box-shadow .16s ease, color .16s ease;
}
.recipient-import-step-item.is-current .recipient-import-step-icon,
.recipient-import-step-item.is-complete .recipient-import-step-icon {
color: #fff;
background: color-mix(in srgb, var(--accent, #5d776c) 86%, #333);
color: var(--on-accent);
background: color-mix(in srgb, var(--accent) 86%, var(--neutral-900));
}
.recipient-import-step-copy {
display: grid;
@@ -2144,7 +2125,7 @@
height: 2px;
margin: 21px 2px 0;
flex: 0 0 auto;
background: color-mix(in srgb, var(--accent, #5d776c) 55%, var(--line));
background: color-mix(in srgb, var(--accent) 55%, var(--line));
opacity: .82;
}
.recipient-import-step-panel {
@@ -2172,7 +2153,89 @@
min-height: 38px;
}
.recipient-import-summary {
grid-template-columns: repeat(4, minmax(120px, 1fr));
grid-template-columns: repeat(5, minmax(120px, 1fr));
}
.address-source-import-controls {
display: grid;
grid-template-columns: auto minmax(220px, 1fr) auto;
align-items: center;
gap: 10px;
}
.address-source-import-controls input[type="search"] {
min-width: 0;
}
.address-source-picker {
display: grid;
gap: 8px;
max-height: 290px;
overflow: auto;
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.address-source-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
width: 100%;
min-height: 58px;
padding: 10px 12px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
box-shadow: var(--shadow-control);
}
.address-source-option:hover,
.address-source-option:focus-visible {
border-color: color-mix(in srgb, var(--accent) 44%, var(--line));
background: var(--bar);
outline: none;
}
.address-source-option.is-selected {
border-color: color-mix(in srgb, var(--accent) 62%, var(--line));
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
box-shadow: inset 0 2px 5px rgba(var(--shadow-color-rgb), .12), var(--shadow-inset-highlight);
}
.address-source-option:disabled {
cursor: not-allowed;
opacity: .62;
}
.address-source-option-main,
.address-source-option-meta {
display: grid;
gap: 4px;
min-width: 0;
}
.address-source-option-main strong {
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.address-source-option-main span,
.address-source-option-meta span {
color: var(--muted);
font-size: 12px;
}
.address-source-option-meta {
justify-items: end;
}
.address-source-option-meta code {
max-width: 180px;
color: var(--text-subtle);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
}
.compact-empty {
min-height: 78px;
padding: 16px;
}
.recipient-import-preview-surface {
overflow: auto;
@@ -2184,7 +2247,7 @@
}
.recipient-import-preview-table th,
.recipient-import-preview-table td {
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
@@ -2208,7 +2271,7 @@
font-size: 12px;
}
.recipient-import-raw-table tr.is-header-row td {
background: color-mix(in srgb, var(--accent, #5d776c) 8%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
font-weight: 700;
}
.recipient-import-mapping-table select,
@@ -2232,7 +2295,7 @@
justify-content: space-between;
gap: 16px;
padding: 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
@@ -2245,9 +2308,9 @@
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border: var(--border-line);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--warning-bg, #fff3cd) 62%, transparent);
background: color-mix(in srgb, var(--warning-bg) 62%, transparent);
}
.recipient-import-unmatched-patterns ul {
display: grid;
@@ -2267,6 +2330,15 @@
}
}
@media (max-width: 760px) {
.address-source-import-controls {
grid-template-columns: 1fr;
}
.address-source-option {
grid-template-columns: 1fr;
}
.address-source-option-meta {
justify-items: start;
}
.recipient-import-summary {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
@@ -2292,7 +2364,7 @@
.admin-details-grid > div {
min-width: 0;
padding: 10px 0;
border-bottom: 1px solid var(--line);
border-bottom: var(--border-line);
}
.admin-details-grid dt {
color: var(--muted);
@@ -2310,8 +2382,8 @@
gap: 5px;
padding: 3px 7px;
border-radius: 999px;
background: #e8eef5;
color: #36566f;
background: var(--info-muted-bg);
color: var(--info-text-muted);
font-size: 11px;
font-weight: 800;
}
@@ -2342,10 +2414,10 @@
.admin-managed-notice {
margin: 0 0 16px;
padding: 12px 14px;
border: 1px solid #c8d6e3;
border: 1px solid var(--info-muted-border);
border-radius: var(--radius-sm);
background: #eef4f8;
color: #294a61;
background: var(--info-bg);
color: var(--info-text-deep);
line-height: 1.45;
}