intermittent commit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 []},
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
151
tests/test_partial_validation.py
Normal file
151
tests/test_partial_validation.py
Normal 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()
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>);
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
};
|
||||
|
||||
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) }]
|
||||
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user