From 2e593b7fa4125e3f69aca55e92d15ae70118bbfd Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 14 Jul 2026 13:22:10 +0200 Subject: [PATCH] intermittent commit --- README.md | 2 +- docs/RECIPIENT_ADDRESS_BOUNDARY.md | 37 +- docs/RECIPIENT_IMPORT_GUIDE.md | 6 +- .../backend/campaign/models.py | 38 +- .../backend/campaign/validation.py | 496 +++++++---- .../backend/change_tracking.py | 56 +- .../backend/dev/mock_campaign.py | 292 +++++-- src/govoplan_campaign/backend/manifest.py | 25 +- .../backend/messages/builder.py | 541 ++++++++---- .../backend/persistence/campaigns.py | 4 +- .../backend/persistence/versions.py | 295 ++++--- .../backend/reports/campaigns.py | 192 +++-- .../backend/reports/emailing.py | 10 +- src/govoplan_campaign/backend/router.py | 811 +++++++++++------- .../backend/schema/campaign.schema.json | 29 +- src/govoplan_campaign/backend/schemas.py | 59 ++ .../backend/sending/execution.py | 6 +- src/govoplan_campaign/backend/sending/jobs.py | 525 ++++++++---- tests/test_partial_validation.py | 151 ++++ webui/src/api/campaigns.ts | 81 ++ webui/src/api/client.ts | 2 +- webui/src/api/mail.ts | 238 ++--- .../features/addressbook/AddressBookPage.tsx | 97 --- .../features/campaigns/RecipientDataPage.tsx | 650 ++++++++++++-- .../campaigns/RecipientDetailsPage.tsx | 75 +- .../src/features/campaigns/ReviewSendPage.tsx | 4 +- .../components/CampaignDraftPageScaffold.tsx | 67 ++ .../features/campaigns/utils/bulkImport.ts | 80 +- webui/src/index.ts | 1 - webui/src/module.ts | 3 - webui/src/styles/campaign-workspace.css | 322 ++++--- 31 files changed, 3523 insertions(+), 1672 deletions(-) create mode 100644 tests/test_partial_validation.py delete mode 100644 webui/src/features/addressbook/AddressBookPage.tsx create mode 100644 webui/src/features/campaigns/components/CampaignDraftPageScaffold.tsx diff --git a/README.md b/README.md index 9e5bc5e..6a44816 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/RECIPIENT_ADDRESS_BOUNDARY.md b/docs/RECIPIENT_ADDRESS_BOUNDARY.md index e6e9193..dd4ee68 100644 --- a/docs/RECIPIENT_ADDRESS_BOUNDARY.md +++ b/docs/RECIPIENT_ADDRESS_BOUNDARY.md @@ -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: diff --git a/docs/RECIPIENT_IMPORT_GUIDE.md b/docs/RECIPIENT_IMPORT_GUIDE.md index d672e03..b30e7fe 100644 --- a/docs/RECIPIENT_IMPORT_GUIDE.md +++ b/docs/RECIPIENT_IMPORT_GUIDE.md @@ -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 diff --git a/src/govoplan_campaign/backend/campaign/models.py b/src/govoplan_campaign/backend/campaign/models.py index 87a7b0a..a02b59f 100644 --- a/src/govoplan_campaign/backend/campaign/models.py +++ b/src/govoplan_campaign/backend/campaign/models.py @@ -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 diff --git a/src/govoplan_campaign/backend/campaign/validation.py b/src/govoplan_campaign/backend/campaign/validation.py index 6806e41..f19408f 100644 --- a/src/govoplan_campaign/backend/campaign/validation.py +++ b/src/govoplan_campaign/backend/campaign/validation.py @@ -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, diff --git a/src/govoplan_campaign/backend/change_tracking.py b/src/govoplan_campaign/backend/change_tracking.py index f913767..8506755 100644 --- a/src/govoplan_campaign/backend/change_tracking.py +++ b/src/govoplan_campaign/backend/change_tracking.py @@ -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"), } diff --git a/src/govoplan_campaign/backend/dev/mock_campaign.py b/src/govoplan_campaign/backend/dev/mock_campaign.py index b71bc7a..271b241 100644 --- a/src/govoplan_campaign/backend/dev/mock_campaign.py +++ b/src/govoplan_campaign/backend/dev/mock_campaign.py @@ -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 []}, } diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 46ecf21..cfeb7e3 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -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( diff --git a/src/govoplan_campaign/backend/messages/builder.py b/src/govoplan_campaign/backend/messages/builder.py index 8756fad..ce629bc 100644 --- a/src/govoplan_campaign/backend/messages/builder.py +++ b/src/govoplan_campaign/backend/messages/builder.py @@ -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( diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py index cdc43d5..4039b2f 100644 --- a/src/govoplan_campaign/backend/persistence/campaigns.py +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -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) diff --git a/src/govoplan_campaign/backend/persistence/versions.py b/src/govoplan_campaign/backend/persistence/versions.py index f263443..272256f 100644 --- a/src/govoplan_campaign/backend/persistence/versions.py +++ b/src/govoplan_campaign/backend/persistence/versions.py @@ -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.") diff --git a/src/govoplan_campaign/backend/reports/campaigns.py b/src/govoplan_campaign/backend/reports/campaigns.py index dbe97df..7e0657c 100644 --- a/src/govoplan_campaign/backend/reports/campaigns.py +++ b/src/govoplan_campaign/backend/reports/campaigns.py @@ -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( diff --git a/src/govoplan_campaign/backend/reports/emailing.py b/src/govoplan_campaign/backend/reports/emailing.py index 561ddca..ffcff4b 100644 --- a/src/govoplan_campaign/backend/reports/emailing.py +++ b/src/govoplan_campaign/backend/reports/emailing.py @@ -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", diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 5c780b1..72c91fc 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -1,6 +1,9 @@ from __future__ import annotations import copy +import dataclasses +from collections.abc import Callable +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy import and_, exists, func, or_ @@ -20,6 +23,13 @@ from govoplan_campaign.backend.schemas import ( CampaignShareTargetsResponse, CampaignShareUpsertRequest, CampaignOwnerUpdateRequest, + CampaignAddressLookupCandidate, + CampaignAddressLookupResponse, + CampaignRecipientAddressSource, + CampaignRecipientAddressSourcesResponse, + CampaignRecipientAddressSourceSnapshotRequest, + CampaignRecipientAddressSourceSnapshotResponse, + CampaignRecipientSnapshotItem, RecipientImportMappingProfileListResponse, RecipientImportMappingProfilePayload, RecipientImportMappingProfileResponse, @@ -88,7 +98,7 @@ from govoplan_campaign.backend.persistence.campaigns import ( validate_campaign_version, ) from govoplan_campaign.backend.integrations import files_integration -from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json +from govoplan_campaign.backend.campaign.loader import load_campaign_json from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments from govoplan_core.security.time import utc_now from govoplan_campaign.backend.persistence.versions import ( @@ -113,6 +123,46 @@ from govoplan_campaign.backend.sending.execution import clear_execution_snapshot router = APIRouter(prefix="/campaigns", tags=["campaigns"]) CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs" +CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" +CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source" + + +def _capability_payload(value: object) -> dict[str, Any]: + if dataclasses.is_dataclass(value): + return dataclasses.asdict(value) + if isinstance(value, dict): + return dict(value) + payload: dict[str, Any] = {} + for key in ( + "contact_id", + "address_book_id", + "display_name", + "email", + "email_label", + "organization", + "role_title", + "tags", + "source_kind", + "source_ref", + "source_revision", + "source_id", + "source_label", + "recipient_count", + "generated_at", + "recipients", + "fields", + "provenance", + ): + if hasattr(value, key): + payload[key] = getattr(value, key) + return payload + + +def _registry_capability(name: str) -> object | None: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name): + return None + return registry.capability(name) def _access_directory() -> AccessDirectory: @@ -275,6 +325,83 @@ def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:use") +def _campaign_version_detail_response( + session: Session, + principal: ApiPrincipal, + campaign_id: str, + mutation: Callable[[], CampaignVersion], + *, + audit_action: str, + details: dict[str, Any] | Callable[[CampaignVersion], dict[str, Any]] | None = None, + validation_error_status: int | None = None, +) -> CampaignVersionDetailResponse: + try: + version = mutation() + audit_details = details(version) if callable(details) else dict(details or {"campaign_id": campaign_id}) + audit_from_principal( + session, + principal, + action=audit_action, + object_type="campaign_version", + object_id=version.id, + details=audit_details, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + if validation_error_status is None: + raise + raise HTTPException(status_code=validation_error_status, detail=str(exc)) from exc + + +def _update_campaign_version_detail_response( + session: Session, + principal: ApiPrincipal, + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest, + *, + autosave: bool, + audit_action: str, +) -> CampaignVersionDetailResponse: + _get_campaign_for_principal(session, campaign_id, principal, write=True) + current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): + _require_permission(principal, "campaigns:recipient:write") + _require_mail_profile_use_if_needed(principal, payload.campaign_json) + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow, + current_step=payload.current_step, + workflow_state=payload.workflow_state, + is_complete=payload.is_complete, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=autosave, + ), + audit_action=audit_action, + details=lambda version: { + "campaign_id": campaign_id, + "current_flow": version.current_flow, + "current_step": version.current_step, + }, + validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT, + ) + + def _require_campaign_profile_use_if_needed( session: Session, principal: ApiPrincipal, @@ -495,6 +622,18 @@ _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS = ( ) +@dataclasses.dataclass(frozen=True, slots=True) +class _WorkspaceDeltaState: + entries: list[object] + relevant_entries: list[object] + has_more: bool + changed_campaign: bool + changed_version_ids: set[str] + selected_version_id: str | None + changed_current_version: bool + summary_invalidated: bool + + def _campaign_delta_watermark(session: Session, tenant_id: str, collections: tuple[str, ...]) -> str: return encode_sequence_watermark( max_sequence_id( @@ -723,6 +862,73 @@ def delete_recipient_import_mapping_profile( return Response(status_code=status.HTTP_204_NO_CONTENT) +@router.get("/{campaign_id}/address-lookup", response_model=CampaignAddressLookupResponse) +def lookup_campaign_addresses( + campaign_id: str, + query: str = Query(default="", min_length=0), + limit: int = Query(default=25, ge=1, le=100), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP) + if capability is None or not hasattr(capability, "lookup"): + return CampaignAddressLookupResponse(available=False, candidates=[]) + candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit) + return CampaignAddressLookupResponse( + available=True, + candidates=[CampaignAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates], + ) + + +@router.get("/{campaign_id}/recipient-address-sources", response_model=CampaignRecipientAddressSourcesResponse) +def list_campaign_recipient_address_sources( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + capability = _registry_capability(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE) + if capability is None or not hasattr(capability, "list_sources"): + return CampaignRecipientAddressSourcesResponse(available=False, sources=[]) + sources = getattr(capability, "list_sources")(session, principal) + return CampaignRecipientAddressSourcesResponse( + available=True, + sources=[CampaignRecipientAddressSource.model_validate(_capability_payload(source)) for source in sources], + ) + + +@router.post("/{campaign_id}/recipient-address-sources/snapshot", response_model=CampaignRecipientAddressSourceSnapshotResponse) +def snapshot_campaign_recipient_address_source( + campaign_id: str, + payload: CampaignRecipientAddressSourceSnapshotRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:recipient:import")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + capability = _registry_capability(CAPABILITY_ADDRESSES_RECIPIENT_SOURCE) + if capability is None or not hasattr(capability, "snapshot"): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Address recipient-source capability is not available") + try: + snapshot = getattr(capability, "snapshot")(session, principal, source_id=payload.source_id) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + snapshot_payload = _capability_payload(snapshot) + recipients = [ + CampaignRecipientSnapshotItem.model_validate(_capability_payload(item)) + for item in snapshot_payload.get("recipients", []) + ] + return CampaignRecipientAddressSourceSnapshotResponse( + source_id=str(snapshot_payload.get("source_id") or ""), + source_label=str(snapshot_payload.get("source_label") or ""), + source_kind=str(snapshot_payload.get("source_kind") or ""), + source_revision=str(snapshot_payload.get("source_revision") or ""), + generated_at=str(snapshot_payload.get("generated_at") or ""), + recipients=recipients, + provenance=snapshot_payload.get("provenance") if isinstance(snapshot_payload.get("provenance"), dict) else {}, + ) + + @router.get("/{campaign_id}", response_model=CampaignResponse) def get_campaign( campaign_id: str, @@ -887,6 +1093,48 @@ def _campaign_workspace_delta_response( include_versions=include_versions, ) + delta = _campaign_workspace_delta_state( + session, + principal, + campaign=campaign, + version_id=version_id, + since_sequence=since_sequence, + limit=limit, + ) + return CampaignWorkspaceDeltaResponse( + campaign=CampaignResponse.model_validate(campaign) if delta.changed_campaign else None, + versions=_campaign_workspace_delta_versions(session, campaign, delta, include_versions=include_versions), + current_version=_campaign_workspace_delta_current_version( + session, + principal, + campaign=campaign, + delta=delta, + include_current_version=include_current_version, + ), + summary=_campaign_workspace_delta_summary( + session, + principal, + campaign_id=campaign_id, + delta=delta, + include_summary=include_summary, + ), + selected_version_id=delta.selected_version_id, + deleted=_campaign_workspace_delta_deleted(delta.relevant_entries), + watermark=_campaign_workspace_delta_watermark(session, principal, delta), + has_more=delta.has_more, + full=False, + ) + + +def _campaign_workspace_delta_state( + session: Session, + principal: ApiPrincipal, + *, + campaign: Campaign, + version_id: str | None, + since_sequence: int, + limit: int, +) -> _WorkspaceDeltaState: entries_plus_one = sequence_entries_since( session, since=since_sequence, @@ -895,79 +1143,108 @@ def _campaign_workspace_delta_response( collections=_CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS, limit=limit + 1, ) - has_more = len(entries_plus_one) > limit entries = entries_plus_one[:limit] relevant_entries = [entry for entry in entries if _entry_belongs_to_campaign(entry, campaign.id)] - - changed_campaign = any(entry.resource_type == "campaign" for entry in relevant_entries) changed_version_ids = { entry.resource_id for entry in relevant_entries if entry.resource_type == "campaign_version" and entry.operation != "deleted" } - deleted = [ + selected_version_id = version_id or campaign.current_version_id + return _WorkspaceDeltaState( + entries=entries, + relevant_entries=relevant_entries, + has_more=len(entries_plus_one) > limit, + changed_campaign=any(entry.resource_type == "campaign" for entry in relevant_entries), + changed_version_ids=changed_version_ids, + selected_version_id=selected_version_id, + changed_current_version=bool(selected_version_id and selected_version_id in changed_version_ids), + summary_invalidated=any( + entry.collection in {CAMPAIGN_JOBS_COLLECTION, CAMPAIGN_ISSUES_COLLECTION, CAMPAIGN_ATTEMPTS_COLLECTION} + for entry in relevant_entries + ), + ) + + +def _campaign_workspace_delta_deleted(entries: list[object]) -> list[dict[str, object]]: + return [ _workspace_delta_deleted_item(entry) - for entry in relevant_entries + for entry in entries if entry.operation == "deleted" and entry.resource_type != "campaign" ] - selected_version_id = version_id or campaign.current_version_id - changed_current_version = bool(selected_version_id and selected_version_id in changed_version_ids) - summary_invalidated = any( - entry.collection in {CAMPAIGN_JOBS_COLLECTION, CAMPAIGN_ISSUES_COLLECTION, CAMPAIGN_ATTEMPTS_COLLECTION} - for entry in relevant_entries - ) - campaign_payload = CampaignResponse.model_validate(campaign) if changed_campaign else None - version_payload: list[CampaignVersionResponse] = [] - if include_versions and changed_version_ids: - versions = ( - session.query(CampaignVersion) - .filter(CampaignVersion.campaign_id == campaign.id, CampaignVersion.id.in_(changed_version_ids)) - .order_by(CampaignVersion.version_number.desc()) - .all() + +def _campaign_workspace_delta_versions( + session: Session, + campaign: Campaign, + delta: _WorkspaceDeltaState, + *, + include_versions: bool, +) -> list[CampaignVersionResponse]: + if not include_versions or not delta.changed_version_ids: + return [] + versions = ( + session.query(CampaignVersion) + .filter(CampaignVersion.campaign_id == campaign.id, CampaignVersion.id.in_(delta.changed_version_ids)) + .order_by(CampaignVersion.version_number.desc()) + .all() + ) + return [CampaignVersionResponse.model_validate(item) for item in versions] + + +def _campaign_workspace_delta_current_version( + session: Session, + principal: ApiPrincipal, + *, + campaign: Campaign, + delta: _WorkspaceDeltaState, + include_current_version: bool, +) -> CampaignVersionDetailResponse | None: + if not include_current_version or not delta.selected_version_id: + return None + if not (delta.changed_campaign or delta.changed_current_version): + return None + _require_permission(principal, "campaigns:recipient:read") + current_version = ( + session.query(CampaignVersion) + .filter(CampaignVersion.id == delta.selected_version_id, CampaignVersion.campaign_id == campaign.id) + .one_or_none() + ) + return CampaignVersionDetailResponse.model_validate(current_version) if current_version is not None else None + + +def _campaign_workspace_delta_summary( + session: Session, + principal: ApiPrincipal, + *, + campaign_id: str, + delta: _WorkspaceDeltaState, + include_summary: bool, +) -> dict[str, object] | None: + if not include_summary: + return None + if not (delta.changed_campaign or delta.changed_version_ids or delta.summary_invalidated): + return None + try: + return generate_campaign_report( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=delta.selected_version_id, + include_jobs=False, ) - version_payload = [CampaignVersionResponse.model_validate(item) for item in versions] + except CampaignReportError: + return None - current_version_payload: CampaignVersionDetailResponse | None = None - if include_current_version and selected_version_id and (changed_campaign or changed_current_version): - _require_permission(principal, "campaigns:recipient:read") - current_version = ( - session.query(CampaignVersion) - .filter(CampaignVersion.id == selected_version_id, CampaignVersion.campaign_id == campaign.id) - .one_or_none() - ) - if current_version is not None: - current_version_payload = CampaignVersionDetailResponse.model_validate(current_version) - summary_payload: dict[str, object] | None = None - if include_summary and (changed_campaign or changed_version_ids or summary_invalidated): - try: - summary_payload = generate_campaign_report( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=selected_version_id, - include_jobs=False, - ) - except CampaignReportError: - summary_payload = None - - watermark = ( - encode_sequence_watermark(entries[-1].id) - if has_more and entries - else _campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS) - ) - return CampaignWorkspaceDeltaResponse( - campaign=campaign_payload, - versions=version_payload, - current_version=current_version_payload, - summary=summary_payload, - selected_version_id=selected_version_id, - deleted=deleted, - watermark=watermark, - has_more=has_more, - full=False, - ) +def _campaign_workspace_delta_watermark( + session: Session, + principal: ApiPrincipal, + delta: _WorkspaceDeltaState, +) -> str: + if delta.has_more and delta.entries: + return encode_sequence_watermark(delta.entries[-1].id) + return _campaign_delta_watermark(session, principal.tenant_id, _CAMPAIGN_WORKSPACE_DELTA_COLLECTIONS) @router.get("/{campaign_id}/workspace/delta", response_model=CampaignWorkspaceDeltaResponse) @@ -1205,27 +1482,18 @@ def unlock_version_validation( that version. Sent/final versions cannot be unlocked and must be copied. """ - try: - version = unlock_validated_campaign_version( + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: unlock_validated_campaign_version( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, - ) - audit_from_principal( - session, - principal, - action="campaign.version_validation_unlocked", - object_type="campaign_version", - object_id=version.id, - details={"campaign_id": campaign_id}, - commit=True, - ) - return CampaignVersionDetailResponse.model_validate(version) - except LockedCampaignVersionError as exc: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + ), + audit_action="campaign.version_validation_unlocked", + ) @router.post("/{campaign_id}/versions/{version_id}/lock-temporarily", response_model=CampaignVersionDetailResponse) @@ -1236,28 +1504,19 @@ def lock_version_temporarily( principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) - try: - version = lock_campaign_version_temporarily( + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: lock_campaign_version_temporarily( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, user_id=principal.user.id, - ) - audit_from_principal( - session, - principal, - action="campaign.version_user_locked_temporarily", - object_type="campaign_version", - object_id=version.id, - details={"campaign_id": campaign_id}, - commit=True, - ) - return CampaignVersionDetailResponse.model_validate(version) - except LockedCampaignVersionError as exc: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + ), + audit_action="campaign.version_user_locked_temporarily", + ) @router.post("/{campaign_id}/versions/{version_id}/unlock-user-lock", response_model=CampaignVersionDetailResponse) @@ -1268,27 +1527,18 @@ def unlock_version_user_lock( principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) - try: - version = unlock_user_locked_campaign_version( + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: unlock_user_locked_campaign_version( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, - ) - audit_from_principal( - session, - principal, - action="campaign.version_user_lock_removed", - object_type="campaign_version", - object_id=version.id, - details={"campaign_id": campaign_id}, - commit=True, - ) - return CampaignVersionDetailResponse.model_validate(version) - except LockedCampaignVersionError as exc: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + ), + audit_action="campaign.version_user_lock_removed", + ) @router.post("/{campaign_id}/versions/{version_id}/lock-permanently", response_model=CampaignVersionDetailResponse) @@ -1299,28 +1549,19 @@ def lock_version_permanently( principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) - try: - version = permanently_lock_campaign_version( + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: permanently_lock_campaign_version( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, user_id=principal.user.id, - ) - audit_from_principal( - session, - principal, - action="campaign.version_user_locked_permanently", - object_type="campaign_version", - object_id=version.id, - details={"campaign_id": campaign_id}, - commit=True, - ) - return CampaignVersionDetailResponse.model_validate(version) - except LockedCampaignVersionError as exc: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + ), + audit_action="campaign.version_user_locked_permanently", + ) @router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) @@ -1331,43 +1572,15 @@ def update_version_detail( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) - if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): - _require_permission(principal, "campaigns:recipient:write") - _require_mail_profile_use_if_needed(principal, payload.campaign_json) - try: - version = update_campaign_version( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - raw_json=payload.campaign_json, - current_flow=payload.current_flow, - current_step=payload.current_step, - workflow_state=payload.workflow_state, - is_complete=payload.is_complete, - editor_state=payload.editor_state, - source_filename=payload.source_filename, - source_base_path=payload.source_base_path, - autosave=False, - ) - audit_from_principal( - session, - principal, - action="campaign.version_updated", - object_type="campaign_version", - object_id=version.id, - details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step}, - commit=True, - ) - return CampaignVersionDetailResponse.model_validate(version) - except LockedCampaignVersionError as exc: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + return _update_campaign_version_detail_response( + session, + principal, + campaign_id, + version_id, + payload, + autosave=False, + audit_action="campaign.version_updated", + ) @router.post("/{campaign_id}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse) @@ -1378,43 +1591,15 @@ def autosave_version( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): - _get_campaign_for_principal(session, campaign_id, principal, write=True) - current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) - if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): - _require_permission(principal, "campaigns:recipient:write") - _require_mail_profile_use_if_needed(principal, payload.campaign_json) - try: - version = update_campaign_version( - session, - tenant_id=principal.tenant_id, - campaign_id=campaign_id, - version_id=version_id, - raw_json=payload.campaign_json, - current_flow=payload.current_flow, - current_step=payload.current_step, - workflow_state=payload.workflow_state, - is_complete=payload.is_complete, - editor_state=payload.editor_state, - source_filename=payload.source_filename, - source_base_path=payload.source_base_path, - autosave=True, - ) - audit_from_principal( - session, - principal, - action="campaign.version_autosaved", - object_type="campaign_version", - object_id=version.id, - details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step}, - commit=True, - ) - return CampaignVersionDetailResponse.model_validate(version) - except LockedCampaignVersionError as exc: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - except Exception as exc: - raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc + return _update_campaign_version_detail_response( + session, + principal, + campaign_id, + version_id, + payload, + autosave=True, + audit_action="campaign.version_autosaved", + ) @router.post("/{campaign_id}/versions/{version_id}/set-step", response_model=CampaignVersionDetailResponse) @@ -1519,28 +1704,19 @@ def publish_version( principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), ): _get_campaign_for_principal(session, campaign_id, principal, write=True) - try: - version = publish_campaign_version( + return _campaign_version_detail_response( + session, + principal, + campaign_id, + lambda: publish_campaign_version( session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id, user_id=principal.user.id, - ) - audit_from_principal( - session, - principal, - action="campaign.version_user_locked_permanently", - object_type="campaign_version", - object_id=version.id, - details={"campaign_id": campaign_id}, - commit=True, - ) - return CampaignVersionDetailResponse.model_validate(version) - except LockedCampaignVersionError as exc: - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc - except CampaignPersistenceError as exc: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + ), + audit_action="campaign.version_user_locked_permanently", + ) @router.post("/versions/{version_id}/validate") @@ -1682,27 +1858,46 @@ def _review_metadata( base_filters: list[object], ) -> tuple[dict[str, object], set[str]]: if version is None: - return { - "inspection_complete": False, - "blocking_count": 0, - "required_count": 0, - "reviewed_required_count": 0, - "bulk_acceptable_count": 0, - }, set() + return _empty_review_metadata(), set() + review_state, state_is_current = _current_review_state(version) + reviewed_keys = _current_reviewed_keys(review_state, state_is_current=state_is_current) + counts = _review_metadata_counts(_review_rows(session, base_filters), reviewed_keys) + return { + "inspection_complete": bool(state_is_current and review_state.get("inspection_complete") is True), + **counts, + }, reviewed_keys + + +def _empty_review_metadata() -> dict[str, object]: + return { + "inspection_complete": False, + "blocking_count": 0, + "required_count": 0, + "reviewed_required_count": 0, + "bulk_acceptable_count": 0, + } + + +def _current_review_state(version: CampaignVersion) -> tuple[dict[str, object], bool]: build_summary = version.build_summary if isinstance(version.build_summary, dict) else {} build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "") editor_state = version.editor_state if isinstance(version.editor_state, dict) else {} review_state = editor_state.get("review_send") if isinstance(editor_state.get("review_send"), dict) else {} state_token = str(review_state.get("build_token") or "") - state_is_current = bool(build_token and state_token == build_token) - reviewed_keys = { + return review_state, bool(build_token and state_token == build_token) + + +def _current_reviewed_keys(review_state: dict[str, object], *, state_is_current: bool) -> set[str]: + return { str(value) for value in (review_state.get("reviewed_message_keys") or []) if state_is_current and str(value).strip() } - review_rows = ( + +def _review_rows(session: Session, base_filters: list[object]) -> list[tuple[object, int, str, str]]: + return ( session.query( CampaignJob.entry_id, CampaignJob.entry_index, @@ -1712,6 +1907,9 @@ def _review_metadata( .filter(*base_filters) .all() ) + + +def _review_metadata_counts(review_rows: list[tuple[object, int, str, str]], reviewed_keys: set[str]) -> dict[str, int]: blocking_count = 0 required_count = 0 reviewed_required_count = 0 @@ -1728,12 +1926,11 @@ def _review_metadata( bulk_acceptable_count += 1 return { - "inspection_complete": bool(state_is_current and review_state.get("inspection_complete") is True), "blocking_count": blocking_count, "required_count": required_count, "reviewed_required_count": reviewed_required_count, "bulk_acceptable_count": bulk_acceptable_count, - }, reviewed_keys + } def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[str, int]]: @@ -1921,17 +2118,32 @@ def _campaign_jobs_delta_watermark(session: Session, tenant_id: str) -> str: ) +class CampaignJobsQuery: + def __init__( + self, + version_id: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + cursor: str | None = Query(default=None), + send_status: list[str] | None = Query(default=None), + validation_status: list[str] | None = Query(default=None), + imap_status: list[str] | None = Query(default=None), + query_text: str | None = Query(default=None, alias="q", max_length=200), + ) -> None: + self.version_id = version_id + self.page = page + self.page_size = page_size + self.cursor = cursor + self.send_status = send_status + self.validation_status = validation_status + self.imap_status = imap_status + self.query_text = query_text + + @router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse) def list_jobs( campaign_id: str, - version_id: str | None = None, - page: int = Query(default=1, ge=1), - page_size: int = Query(default=50, ge=1, le=200), - cursor: str | None = Query(default=None), - send_status: list[str] | None = Query(default=None), - validation_status: list[str] | None = Query(default=None), - imap_status: list[str] | None = Query(default=None), - query_text: str | None = Query(default=None, alias="q", max_length=200), + filters: CampaignJobsQuery = Depends(CampaignJobsQuery), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), ): @@ -1945,27 +2157,27 @@ def list_jobs( session, principal, campaign_id=campaign_id, - version_id=version_id, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, + version_id=filters.version_id, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, ) return _campaign_jobs_page_response( session, campaign_id=campaign_id, - version_id=version_id, + version_id=filters.version_id, base_filters=base_filters, filtered=filtered, reviewed_keys=reviewed_keys, review_metadata=review_metadata, - page=page, - page_size=page_size, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, - cursor=cursor, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + cursor=filters.cursor, ) @@ -2031,14 +2243,7 @@ def _job_filter_membership_can_shift( @router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse) def list_jobs_delta( campaign_id: str, - version_id: str | None = None, - page: int = Query(default=1, ge=1), - page_size: int = Query(default=50, ge=1, le=200), - cursor: str | None = Query(default=None), - send_status: list[str] | None = Query(default=None), - validation_status: list[str] | None = Query(default=None), - imap_status: list[str] | None = Query(default=None), - query_text: str | None = Query(default=None, alias="q", max_length=200), + filters: CampaignJobsQuery = Depends(CampaignJobsQuery), since: str | None = None, limit: int = Query(default=500, ge=1, le=1000), session: Session = Depends(get_session), @@ -2049,25 +2254,25 @@ def list_jobs_delta( session, principal=principal, campaign_id=campaign_id, - version_id=version_id, - page=page, - page_size=page_size, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, - cursor=cursor, + version_id=filters.version_id, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + cursor=filters.cursor, ) campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context( session, principal, campaign_id=campaign_id, - version_id=version_id, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, + version_id=filters.version_id, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, ) try: since_sequence = decode_sequence_watermark(since) @@ -2084,14 +2289,14 @@ def list_jobs_delta( session, principal=principal, campaign_id=campaign_id, - version_id=version_id, - page=page, - page_size=page_size, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, - cursor=cursor, + version_id=filters.version_id, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + cursor=filters.cursor, ) entries_plus_one = sequence_entries_since( @@ -2108,15 +2313,15 @@ def list_jobs_delta( entry for entry in entries if (entry.payload or {}).get("campaign_id") == campaign.id - and (not version_id or (entry.payload or {}).get("version_id") == version_id) + and (not filters.version_id or (entry.payload or {}).get("version_id") == filters.version_id) ] if relevant_entries and ( _job_filter_membership_can_shift( - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, ) or any(entry.operation in {"created", "deleted"} for entry in relevant_entries) ): @@ -2124,14 +2329,14 @@ def list_jobs_delta( session, principal=principal, campaign_id=campaign_id, - version_id=version_id, - page=page, - page_size=page_size, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, - cursor=cursor, + version_id=filters.version_id, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + cursor=filters.cursor, ) changed_job_ids = { @@ -2142,18 +2347,18 @@ def list_jobs_delta( payload = _campaign_jobs_page_response( session, campaign_id=campaign_id, - version_id=version_id, + version_id=filters.version_id, base_filters=base_filters, filtered=filtered, reviewed_keys=reviewed_keys, review_metadata=review_metadata, - page=page, - page_size=page_size, - send_status=send_status, - validation_status=validation_status, - imap_status=imap_status, - query_text=query_text, - cursor=cursor, + page=filters.page, + page_size=filters.page_size, + send_status=filters.send_status, + validation_status=filters.validation_status, + imap_status=filters.imap_status, + query_text=filters.query_text, + cursor=filters.cursor, changed_job_ids=changed_job_ids, ) watermark = ( @@ -2905,7 +3110,7 @@ def preview_campaign_attachments( campaign_id=campaign.id, raw_json=raw, include_bytes=False, - prefix="multimailer-managed-preview-", + prefix="govoplan-managed-preview-", ) as prepared: prepared_raw = load_campaign_json(prepared.path) config = load_campaign_config_from_json(session, tenant_id=principal.tenant_id, raw_json=prepared_raw, campaign_id=campaign.id) diff --git a/src/govoplan_campaign/backend/schema/campaign.schema.json b/src/govoplan_campaign/backend/schema/campaign.schema.json index 9534a64..aa93351 100644 --- a/src/govoplan_campaign/backend/schema/campaign.schema.json +++ b/src/govoplan_campaign/backend/schema/campaign.schema.json @@ -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", diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index dff2526..223de6c 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -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 diff --git a/src/govoplan_campaign/backend/sending/execution.py b/src/govoplan_campaign/backend/sending/execution.py index aa0f8b5..0550280 100644 --- a/src/govoplan_campaign/backend/sending/execution.py +++ b/src/govoplan_campaign/backend/sending/execution.py @@ -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), diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py index e1d67e1..313512a 100644 --- a/src/govoplan_campaign/backend/sending/jobs.py +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -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: diff --git a/tests/test_partial_validation.py b/tests/test_partial_validation.py new file mode 100644 index 0000000..d53609f --- /dev/null +++ b/tests/test_partial_validation.py @@ -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() diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 2f2653b..f29adf6 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -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; +}; + +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; +}; + +export type CampaignRecipientAddressSourcesResponse = { + available: boolean; + sources: CampaignRecipientAddressSource[]; +}; + +export type CampaignRecipientSnapshotItem = { + contact_id: string; + display_name: string; + email: string; + email_label?: string | null; + fields: Record; + provenance: Record; +}; + +export type CampaignRecipientAddressSourceSnapshot = { + source_id: string; + source_label: string; + source_kind: string; + source_revision: string; + generated_at: string; + recipients: CampaignRecipientSnapshotItem[]; + provenance: Record; +}; + export type CampaignVersionUpdatePayload = { campaign_json?: Record | null; current_flow?: string | null; @@ -419,6 +472,34 @@ export async function deleteRecipientImportMappingProfile(settings: ApiSettings, await apiFetch(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 { + const params = new URLSearchParams({ query, limit: String(limit) }); + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/address-lookup?${params.toString()}`); +} + +export async function listCampaignRecipientAddressSources( +settings: ApiSettings, +campaignId: string) +: Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`); +} + +export async function snapshotCampaignRecipientAddressSource( +settings: ApiSettings, +campaignId: string, +sourceId: string) +: Promise { + return apiFetch(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 { return apiFetch(settings, `/api/v1/campaigns/${campaignId}`); } diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts index c1673ff..cd33fb9 100644 --- a/webui/src/api/client.ts +++ b/webui/src/api/client.ts @@ -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"; diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 9b57023..f01076d 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -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( + settings: ApiSettings, + profileId: string, + action: keyof typeof profileActionEndpoints +): Promise { + return apiPost( + 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; -}; - -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; -}; - -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>; - -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( + settings: ApiSettings, + payload: TPayload, + action: keyof typeof rawSettingsEndpoints +): Promise { + return apiPostJson(settings, rawSettingsEndpoints[action], payload); +} export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise { - 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(settings, `/api/v1/mail/profiles${suffix}`); - return response.profiles ?? []; + return apiGetList(settings, "/api/v1/mail/profiles", "profiles", { + include_inactive: includeInactive ? true : undefined, + campaign_id: campaignId + }); } export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise { @@ -152,71 +65,40 @@ export async function getMailProfilePolicy( scopeId?: string | null, campaignId?: string | null ): Promise { - 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(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`); + return apiFetch(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, { + scope_id: scopeId, + campaign_id: campaignId + })); } export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise { - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" }); + return runProfileAction(settings, profileId, "smtp"); } export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise { - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" }); + return runProfileAction(settings, profileId, "imap"); } export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise { - return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" }); + return runProfileAction(settings, profileId, "folders"); } export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise { - return apiFetch(settings, "/api/v1/mail/test-smtp", { - method: "POST", - body: JSON.stringify(payload) - }); + return runRawSettingsAction(settings, payload, "smtp"); } export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise { - return apiFetch(settings, "/api/v1/mail/test-imap", { - method: "POST", - body: JSON.stringify(payload) - }); + return runRawSettingsAction(settings, payload, "imap"); } export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise { - return apiFetch(settings, "/api/v1/mail/list-imap-folders", { - method: "POST", - body: JSON.stringify(payload) - }); + return runRawSettingsAction(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; - 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 { return apiFetch(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"; diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx deleted file mode 100644 index ce6c36e..0000000 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ /dev/null @@ -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) => {contact.name}, 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: () => , value: () => "mock" }]; - -} - -export default function AddressBookPage() { - const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts]; - - return ( -
-
-
- i18n:govoplan-campaign.address_book.f6327f59 -

i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4

-
-
- - -
-
- -
- {personalContacts.length}

i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556

- {groupContacts.length}

i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d

- {tenantContacts.length}

i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b

- i18n:govoplan-campaign.mock.3bba2a47

i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513

-
- -
- -
- - - -
-
- -
- i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4 - i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529 - i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464 - i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a -
-
-
- - i18n:govoplan-campaign.manage_sources.ec758de0}> - contact.source + "-" + contact.email} - emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09" - className="compact-table-wrap module-table" /> - - -
); - -} - -function AddressBookScope({ title, description, status }: {title: string;description: string;status: string;}) { - return ( -
-
- {title} -

{description}

-
- -
); - -} diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx index b541ac5..51ac482 100644 --- a/webui/src/features/campaigns/RecipientDataPage.tsx +++ b/webui/src/features/campaigns/RecipientDataPage.tsx @@ -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([]); + const [addressLookupQuery, setAddressLookupQuery] = useState(""); + const [addressLookupSuggestions, setAddressLookupSuggestions] = useState([]); + const [addressLookupCache, setAddressLookupCache] = useState>({}); 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 = { @@ -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[]) { 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 ( -
-
-
- i18n:govoplan-campaign.sender_recipients.922c6d24 - -
-
- - -
-
+ <> + saveDraft("manual")} + dirty={dirty} + locked={locked} + draft={draft} + error={error} + localError={localError} + currentVersionId={data.campaign?.current_version_id}> - {error && {error}} - {localError && {localError}} - {locked && } - - - <>
- setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2}> + + {(addressSourcesAvailable || addressSourcesLoading) && + + } + +
+ }> {inlineEntries.length === 0 && Boolean(source.type) && i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4 } + {staleAddressImports.length > 0 && + +
+ Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}. +
+ {staleAddressImports.map((item) => + + )} +
+
+
+ } {!source.type &&
String(entry.id || index)} emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026" emptyAction={ addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />} @@ -354,9 +482,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
} - - - + {importOpen && } -
); + {addressSourceImportOpen && + 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("all"); + const [sourceQuery, setSourceQuery] = useState(""); + const [mode, setMode] = useState("append"); + const [snapshot, setSnapshot] = useState(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 ( + + + + + }> + +
+
+ +
+ setSourceQuery(event.target.value)} + /> + +
+ +
+
+ {filteredSources.map((source) => + + )} + {sources.length > 0 && filteredSources.length === 0 && +
No address sources match the current filter.
+ } +
+ + + +
+ + {error && {error}} + {loading && Loading address recipients...} + {!loading && sources.length === 0 && No address sources are available to this campaign. Create an address book or address list in the addresses module first.} + + {snapshot && + <> +
+
Source
{snapshot.source_label}
+
Type
{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}
+
Scope
{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}
+
Recipients
{snapshot.recipients.length}
+
Revision
{snapshot.source_revision}
+
+
+ + + + + + + + + + + {snapshot.recipients.slice(0, 20).map((recipient, index) => + + + + + + + )} + +
#NameEmailFields
{index + 1}{recipient.display_name}{recipient.email}{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}
+
+ {snapshot.recipients.length > 20 &&

{snapshot.recipients.length - 20} more recipients will be imported.

} + + } +
); } 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[]): 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(); + 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 | undefined): Record { + const fields: Record = {}; + 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 { + 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[]; entryDefaults: Record; 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, checked: boolean) => void; updateEntry: (index: number, updater: (entry: Record) => Record) => 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>[] { +function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, onAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn>[] { return [ { id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => {index + 1}, value: (_entry, index) => index + 1 }, ...entryAddressColumns.map((column): DataGridColumn> => ({ @@ -1222,6 +1747,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC updateEntryMerge(index, column.mergeKey!, checked)} /> - i18n:govoplan-campaign.merge_with_global.ba230098 {column.label} + {translateText("i18n:govoplan-campaign.merge_with_global.ba230098")} {translateText(column.label)}
} , diff --git a/webui/src/features/campaigns/RecipientDetailsPage.tsx b/webui/src/features/campaigns/RecipientDetailsPage.tsx index bdef3eb..68fea43 100644 --- a/webui/src/features/campaigns/RecipientDetailsPage.tsx +++ b/webui/src/features/campaigns/RecipientDetailsPage.tsx @@ -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 ( -
-
-
- i18n:govoplan-campaign.recipient_data.c2baaf10 - -
-
- - -
-
+ <> + saveDraft("manual")} + dirty={dirty} + locked={locked} + draft={draft} + error={error} + localError={localError} + currentVersionId={data.campaign?.current_version_id}> - {error && {error}} - {localError && {localError}} - {locked && } - - - <> setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2}> {inlineEntries.length === 0 && !source.type &&

i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224

} {inlineEntries.length === 0 && Boolean(source.type) && @@ -158,8 +130,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: {settings
} - - + {importOpen && } - ); + ); } diff --git a/webui/src/features/campaigns/ReviewSendPage.tsx b/webui/src/features/campaigns/ReviewSendPage.tsx index 90246a5..76df7bd 100644 --- a/webui/src/features/campaigns/ReviewSendPage.tsx +++ b/webui/src/features/campaigns/ReviewSendPage.tsx @@ -95,8 +95,8 @@ const stateColors: Record = { 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)" }; diff --git a/webui/src/features/campaigns/components/CampaignDraftPageScaffold.tsx b/webui/src/features/campaigns/components/CampaignDraftPageScaffold.tsx new file mode 100644 index 0000000..826d107 --- /dev/null +++ b/webui/src/features/campaigns/components/CampaignDraftPageScaffold.tsx @@ -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; + onSave: () => void; + dirty: boolean; + locked: boolean; + draft: Record | 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 ( +
+
+
+ {title} + +
+
+ + +
+
+ + {error && {error}} + {localError && {localError}} + {locked && } + + + <>{children} + +
); + +} diff --git a/webui/src/features/campaigns/utils/bulkImport.ts b/webui/src/features/campaigns/utils/bulkImport.ts index 9dbf9c0..3432980 100644 --- a/webui/src/features/campaigns/utils/bulkImport.ts +++ b/webui/src/features/campaigns/utils/bulkImport.ts @@ -4,7 +4,6 @@ type XlsxWorkbookSheet = { data: unknown[][]; }; type XlsxWorkbookReader = (input: ArrayBuffer) => Promise; -type RawXlsxWorkbookReader = (input: unknown) => Promise; 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; filename?: string | null; sheet_name?: string | null; encoding?: string | null; @@ -211,15 +218,6 @@ async function loadXlsxWorkbookReader(): Promise { 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, +preview: RecipientImportPreview, +options: MaterializeImportOptions) +: Record { + 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 { 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()); } diff --git a/webui/src/index.ts b/webui/src/index.ts index 4f92cab..0cb0c46 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/module.ts b/webui/src/module.ts index 10158ef..66c2b5a 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -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) }] }; diff --git a/webui/src/styles/campaign-workspace.css b/webui/src/styles/campaign-workspace.css index b2ebfcf..385ca31 100644 --- a/webui/src/styles/campaign-workspace.css +++ b/webui/src/styles/campaign-workspace.css @@ -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; }