diff --git a/.gitignore b/.gitignore index 95f869e..598efec 100644 --- a/.gitignore +++ b/.gitignore @@ -326,4 +326,8 @@ cython_debug/ # Built Visual Studio Code Extensions *.vsix -**/runtime/ \ No newline at end of file +**/runtime/ + +# GovOPlaN WebUI test output +webui/.policy-test-build/ +webui/.template-preview-test-build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ed68906 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# GovOPlaN Campaign Codex Guide + +## Scope + +This repository owns the `campaigns` module: campaign authoring, validation, message building, attachment resolution, queue/review/send control, reports, campaign module manifest, and `@govoplan/campaign-webui`. + +Core owns auth, tenants, RBAC, database/session primitives, CSRF/API helpers, shared components, and shell layout. Files and mail integrations are optional and must be accessed through core module metadata or capabilities. + +## Local Commands + +Install and run through core: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -r requirements-dev.txt +``` + +Focused WebUI tests: + +```bash +cd /mnt/DATA/git/govoplan-campaign/webui +PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:policy-ui +PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:template-preview +``` + +For combined checks, run: + +```bash +cd /mnt/DATA/git/govoplan-core +./scripts/check-focused.sh +``` + +## Working Rules + +- Do not add required files/mail imports for campaign startup. +- Use core-provided capabilities or module metadata for optional file chooser, managed file usage, mail profile, delivery, and mailbox behavior. +- Shared WebUI components belong in core; campaign WebUI should consume them through `@govoplan/core-webui`. +- Avoid DataGrid changes unless explicitly requested. diff --git a/README.md b/README.md index c001cf8..d868018 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,16 @@ Core owns auth, tenants, RBAC evaluation, database/session primitives, CSRF/API ## Dependencies -The module has runtime dependencies on: +The module has one required runtime dependency: -- `govoplan-core` for platform services -- `govoplan-files` for managed attachment integration -- `govoplan-mail` for SMTP/IMAP profile and delivery integration +- `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration -Optional UI behavior can still check installed module availability through core module metadata, but the current backend package declares files and mail as required. +Files and mail are optional module integrations declared in the campaign manifest: + +- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Without it, campaigns can still use legacy/local attachment paths where configured. +- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable. + +Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed. ## Development @@ -60,4 +63,4 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`. ## Release packaging -The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. The campaign WebUI package depends on the tagged files and mail WebUI packages for release builds. +The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. Files and mail WebUI packages remain optional product-composition dependencies supplied by the core host build, not required campaign package dependencies. diff --git a/package.json b/package.json index 254f235..5adf2a3 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,6 @@ "README.md", "LICENSE" ], - "dependencies": { - "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.1", - "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.1" - }, "peerDependencies": { "@govoplan/core-webui": "^0.1.1", "lucide-react": "^0.555.0", diff --git a/pyproject.toml b/pyproject.toml index 35a8340..63e5880 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,16 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-campaign" -version = "0.1.1" +version = "0.1.2" description = "GovOPlaN campaigns module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.1", - "govoplan-files>=0.1.1", - "govoplan-mail>=0.1.1", + "govoplan-core>=0.1.2", "jsonschema>=4,<5", "pydantic>=2,<3", "SQLAlchemy>=2,<3", diff --git a/src/govoplan_campaign/backend/attachments/resolver.py b/src/govoplan_campaign/backend/attachments/resolver.py index 9ce8c82..f675c04 100644 --- a/src/govoplan_campaign/backend/attachments/resolver.py +++ b/src/govoplan_campaign/backend/attachments/resolver.py @@ -2,6 +2,8 @@ from __future__ import annotations import fnmatch import re +import time +from dataclasses import dataclass, field from enum import StrEnum from pathlib import Path from typing import Any, Iterable @@ -69,6 +71,10 @@ class ResolvedAttachment(BaseModel): zip_mode: ZipRuleMode = ZipRuleMode.INHERIT zip_archive_id: str | None = None zip_filename: str | None = None + message_filename_template: str | None = None + zip_entry_name_template: str | None = None + message_filenames: list[str] = Field(default_factory=list) + zip_entry_names: list[str] = Field(default_factory=list) status: AttachmentMatchStatus behavior: Behavior | None = None matches: list[str] = Field(default_factory=list) @@ -99,6 +105,7 @@ class AttachmentResolutionReport(BaseModel): attachments_base_path: str entries_count: int entries: list[EntryAttachmentResolution] = Field(default_factory=list) + profile: dict[str, Any] = Field(default_factory=dict) @property def ready_count(self) -> int: @@ -304,7 +311,55 @@ def _resolve_attachment_directory( return (legacy_root / rendered_base_dir).resolve(), None -def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]: +@dataclass(slots=True) +class AttachmentMatchIndex: + _files_by_directory: dict[tuple[str, bool], list[Path]] = field(default_factory=dict) + filesystem_scans: int = 0 + indexed_files: int = 0 + fallback_globs: int = 0 + + def _directory_key(self, directory: Path, *, recursive: bool) -> tuple[str, bool]: + return str(directory.resolve()), recursive + + def _indexed_files(self, directory: Path, *, recursive: bool) -> list[Path]: + key = self._directory_key(directory, recursive=recursive) + if key not in self._files_by_directory: + iterator = directory.rglob("*") if recursive else directory.iterdir() + files = sorted(path for path in iterator if path.is_file()) + self._files_by_directory[key] = files + self.filesystem_scans += 1 + self.indexed_files += len(files) + return self._files_by_directory[key] + + def iter_files(self, directory: Path, *, recursive: bool = True) -> list[Path]: + if not directory.exists() or not directory.is_dir(): + return [] + return list(self._indexed_files(directory, recursive=recursive)) + + def match_files(self, directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]: + if not directory.exists() or not directory.is_dir(): + return [] + if not include_subdirs and ("/" in file_filter or "\\" in file_filter): + # Preserve pathlib.glob semantics for legacy filters that include a + # relative path segment instead of only a filename pattern. + self.fallback_globs += 1 + return sorted(path for path in directory.glob(file_filter) if path.is_file()) + return sorted(path for path in self._indexed_files(directory, recursive=include_subdirs) if fnmatch.fnmatch(path.name, file_filter)) + + def stats(self, *, duration_ms: float, rules_resolved: int) -> dict[str, Any]: + return { + "duration_ms": round(duration_ms, 2), + "rules_resolved": rules_resolved, + "indexed_directories": len(self._files_by_directory), + "filesystem_scans": self.filesystem_scans, + "indexed_files": self.indexed_files, + "fallback_globs": self.fallback_globs, + } + + +def _match_files(directory: Path, file_filter: str, include_subdirs: bool, match_index: AttachmentMatchIndex | None = None) -> list[Path]: + if match_index is not None: + return match_index.match_files(directory, file_filter, include_subdirs) if not directory.exists() or not directory.is_dir(): return [] if include_subdirs: @@ -343,6 +398,7 @@ def _resolve_one_config( scope: AttachmentScope, index: int, config: AttachmentConfig, + match_index: AttachmentMatchIndex | None = None, ) -> ResolvedAttachment: rendered_base_dir = _rendered_base_dir(config, values) rendered_file_filter = _render_template(config.file_filter, values) @@ -352,7 +408,7 @@ def _resolve_one_config( attachment_config=config, rendered_base_dir=rendered_base_dir, ) - matches = _match_files(directory, rendered_file_filter, config.include_subdirs) + matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index) allow_multiple = _rule_allows_multiple(config, rendered_file_filter) issues: list[AttachmentIssue] = [] @@ -389,6 +445,8 @@ def _resolve_one_config( zip_mode=config.zip.mode, zip_archive_id=archive.id if archive else None, zip_filename=_render_zip_filename(archive, values), + message_filename_template=config.message_filename_template, + zip_entry_name_template=config.zip_entry_name_template, status=status, behavior=behavior, matches=[str(path) for path in matches], @@ -417,6 +475,7 @@ def resolve_entry_attachments( campaign_file: str | Path, entry: EntryConfig, entry_index: int, + match_index: AttachmentMatchIndex | None = None, ) -> EntryAttachmentResolution: values = build_template_values(config, entry) resolved: list[ResolvedAttachment] = [] @@ -431,6 +490,7 @@ def resolve_entry_attachments( scope=scope, index=index, config=attachment_config, + match_index=match_index, ) ) @@ -448,10 +508,13 @@ def resolve_entry_attachments( def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport: entries = load_campaign_entries(config, campaign_file=campaign_file) base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path) + started = time.perf_counter() + match_index = AttachmentMatchIndex() resolved_entries = [ - resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index) + resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index, match_index=match_index) for index, entry in enumerate(entries, start=1) ] + rules_resolved = sum(len(entry.attachments) for entry in resolved_entries) return AttachmentResolutionReport( campaign_id=config.campaign.id, campaign_name=config.campaign.name, @@ -459,4 +522,5 @@ def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | attachments_base_path=str(base_path), entries_count=len(entries), entries=resolved_entries, + profile=match_index.stats(duration_ms=(time.perf_counter() - started) * 1000, rules_resolved=rules_resolved), ) diff --git a/src/govoplan_campaign/backend/campaign/loader.py b/src/govoplan_campaign/backend/campaign/loader.py index 7721810..0c93771 100644 --- a/src/govoplan_campaign/backend/campaign/loader.py +++ b/src/govoplan_campaign/backend/campaign/loader.py @@ -47,11 +47,20 @@ def _default_schema_path() -> Path: return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json" +def _default_schema_ui_path() -> Path: + return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.ui.json" + + def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]: path = Path(schema_path) if schema_path else _default_schema_path() return load_campaign_json(path) +def load_campaign_schema_ui(schema_path: str | Path | None = None) -> dict[str, Any]: + path = Path(schema_path) if schema_path else _default_schema_ui_path() + return load_campaign_json(path) + + def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None: schema = load_campaign_schema(schema_path) validator = Draft202012Validator(schema, format_checker=FormatChecker()) diff --git a/src/govoplan_campaign/backend/campaign/models.py b/src/govoplan_campaign/backend/campaign/models.py index 0d03742..2e9833b 100644 --- a/src/govoplan_campaign/backend/campaign/models.py +++ b/src/govoplan_campaign/backend/campaign/models.py @@ -6,7 +6,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity +from govoplan_core.mail.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials, TransportSecurity class StrictModel(BaseModel): @@ -108,12 +108,58 @@ class FieldDefinition(StrictModel): can_override: bool = True +class MailServerCredentials(StrictModel): + smtp: TransportCredentials = Field(default_factory=TransportCredentials) + imap: TransportCredentials = Field(default_factory=TransportCredentials) + + class ServerConfig(StrictModel): mail_profile_id: str | None = None inherit_smtp_credentials: bool = True inherit_imap_credentials: bool = True - smtp: SmtpConfig | None = None - imap: ImapConfig | None = None + smtp: SmtpServerConfig | None = None + imap: ImapServerConfig | None = None + credentials: MailServerCredentials = Field(default_factory=MailServerCredentials) + + @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 + + def runtime_smtp_config(self) -> SmtpConfig | None: + if self.smtp is None: + return None + payload = self.smtp.model_dump(mode="json") + payload.update(self.credentials.smtp.model_dump(mode="json", exclude_none=True)) + return SmtpConfig.model_validate(payload) + + def runtime_imap_config(self) -> ImapConfig | None: + if self.imap is None: + return None + payload = self.imap.model_dump(mode="json") + payload.update(self.credentials.imap.model_dump(mode="json", exclude_none=True)) + return ImapConfig.model_validate(payload) class RecipientConfig(StrictModel): @@ -180,10 +226,17 @@ class TemplateSourceConfig(StrictModel): return self +class TemplateBodyMode(StrEnum): + TEXT = "text" + HTML = "html" + BOTH = "both" + + class TemplateConfig(StrictModel): subject: str | None = None text: str | None = None html: str | None = None + body_mode: TemplateBodyMode = TemplateBodyMode.BOTH source: TemplateSourceConfig | None = None @model_validator(mode="after") @@ -343,6 +396,8 @@ class AttachmentConfig(StrictModel): include_subdirs: bool = False required: bool = True allow_multiple: bool = False + message_filename_template: str | None = None + zip_entry_name_template: str | None = None @field_validator("type_", mode="before") @classmethod diff --git a/src/govoplan_campaign/backend/campaign/validation.py b/src/govoplan_campaign/backend/campaign/validation.py index d3a46c7..6806e41 100644 --- a/src/govoplan_campaign/backend/campaign/validation.py +++ b/src/govoplan_campaign/backend/campaign/validation.py @@ -301,25 +301,27 @@ def validate_campaign_config( issues.extend(_attachment_path_issues(config)) issues.extend(_zip_configuration_issues(config)) - if config.server.imap and config.server.imap.enabled: - missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")] - if missing: + runtime_imap = config.server.runtime_imap_config() + if config.delivery.imap_append_sent.enabled: + if runtime_imap is None: issues.append(_issue( - Severity.ERROR, - "incomplete_imap_config", - "IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing), - "/server/imap", + 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", + )) - if config.delivery.imap_append_sent.enabled and not (config.server.imap and config.server.imap.enabled): - issues.append(_issue( - Severity.WARNING, - "delivery_imap_enabled_without_server_imap", - "delivery.imap_append_sent is enabled, but server.imap.enabled is not true", - "/delivery/imap_append_sent/enabled", - )) - - if config.campaign.mode == "send" and not config.server.smtp: + runtime_smtp = config.server.runtime_smtp_config() + if config.campaign.mode == "send" and not runtime_smtp: issues.append(_issue( Severity.ERROR, "missing_smtp_config", @@ -327,8 +329,8 @@ def validate_campaign_config( "/server/smtp", )) - if config.server.smtp: - missing = [name for name in ["host", "port"] if getattr(config.server.smtp, name) in (None, "")] + if runtime_smtp: + missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")] if missing: issues.append(_issue( Severity.WARNING, diff --git a/src/govoplan_campaign/backend/dev/mock_campaign.py b/src/govoplan_campaign/backend/dev/mock_campaign.py index 4c29233..3f56165 100644 --- a/src/govoplan_campaign/backend/dev/mock_campaign.py +++ b/src/govoplan_campaign/backend/dev/mock_campaign.py @@ -2,36 +2,41 @@ from __future__ import annotations from email import policy from email.message import EmailMessage +from importlib import import_module +from types import ModuleType from typing import Any from sqlalchemy.orm import Session from govoplan_campaign.backend.db.models import Campaign, CampaignVersion +from govoplan_core.core.optional import reraise_unless_missing_package from govoplan_campaign.backend.campaign.loader import load_campaign_json from govoplan_campaign.backend.campaign.validation import validate_campaign_config from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus -from govoplan_files.backend.storage.campaign_attachments import ( - annotate_built_messages_with_managed_files, - prepared_campaign_snapshot, - public_attachment_summary_payload, -) -from govoplan_mail.backend.dev.mock_mailbox import ( - clear_records, - consume_fail_next_imap, - consume_fail_next_smtp, - get_failures, - list_records, - record_imap_append, - record_smtp_delivery, -) +from govoplan_campaign.backend.integrations import files_integration class MockCampaignSendError(RuntimeError): pass +def _mock_mailbox() -> ModuleType | None: + try: + return import_module("govoplan_mail.backend.dev.mock_mailbox") + except ModuleNotFoundError as exc: + reraise_unless_missing_package(exc, "govoplan_mail") + return None + + +def _require_mock_mailbox() -> ModuleType: + mailbox = _mock_mailbox() + if mailbox is None: + raise MockCampaignSendError("Mail module is not available") + return mailbox + + def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None: if address is None: return None @@ -47,7 +52,7 @@ def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]: def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]: - return [public_attachment_summary_payload(attachment) for attachment in message.attachments] + return [files_integration().public_attachment_summary_payload(attachment) for attachment in message.attachments] def _message_payload(message: MessageDraft) -> dict[str, Any]: @@ -92,7 +97,8 @@ def _raw_message_bytes(message: EmailMessage) -> bytes: def _smtp_rejection_matches(recipients: list[str]) -> list[str]: - needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower() + mailbox = _mock_mailbox() + needle = ((mailbox.get_failures() if mailbox else {}).get("smtp_reject_recipients_containing") or "").strip().lower() if not needle: return [] return [recipient for recipient in recipients if needle in recipient.lower()] @@ -148,10 +154,12 @@ def run_mock_campaign_send( if not version or version.campaign_id != campaign.id: raise MockCampaignSendError("Campaign version not found or not part of campaign") - if clear_mailbox: - clear_records() + mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox() + if clear_mailbox and mailbox is not None: + mailbox.clear_records() - with prepared_campaign_snapshot( + files = files_integration() + with files.prepared_campaign_snapshot( session, tenant_id=tenant_id, campaign_id=campaign.id, @@ -163,7 +171,7 @@ def run_mock_campaign_send( config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id) validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files) build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False) - annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path) + 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 @@ -206,14 +214,14 @@ def run_mock_campaign_send( continue try: - if consume_fail_next_smtp(): + 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 = record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local") + 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", @@ -226,14 +234,14 @@ def run_mock_campaign_send( if append_sent: try: - if consume_fail_next_imap(): + 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 = record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local") + 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: @@ -286,5 +294,5 @@ def run_mock_campaign_send( "imap_failed_count": imap_failed_count, "results": send_results, }, - "mailbox": {"messages": list_records(limit=200)}, + "mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []}, } diff --git a/src/govoplan_campaign/backend/domain/__init__.py b/src/govoplan_campaign/backend/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/govoplan_campaign/backend/domain/campaign.py b/src/govoplan_campaign/backend/domain/campaign.py deleted file mode 100644 index f26f833..0000000 --- a/src/govoplan_campaign/backend/domain/campaign.py +++ /dev/null @@ -1,210 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import StrEnum -from pathlib import Path - -from .fields import Field, FieldConfiguration, FieldContents -from .recipients import Recipient, RecipientList -from .template import MailTemplate - - -class TransportSecurity(StrEnum): - PLAIN = "plain" - TLS = "tls" - STARTTLS = "starttls" - - @property - def standard_port(self) -> int: - return 465 if self == TransportSecurity.TLS else 587 - - -@dataclass -class MailServerSettings: - server: str = "" - port: int | None = None - username: str = "" - password: str = "" - transport_security: TransportSecurity = TransportSecurity.PLAIN - - def use_plain(self) -> "MailServerSettings": - self.transport_security = TransportSecurity.PLAIN - self.port = self.port or self.transport_security.standard_port - return self - - def use_tls(self) -> "MailServerSettings": - self.transport_security = TransportSecurity.TLS - self.port = self.port or self.transport_security.standard_port - return self - - def use_starttls(self) -> "MailServerSettings": - self.transport_security = TransportSecurity.STARTTLS - self.port = self.port or self.transport_security.standard_port - return self - - def resolved_port(self) -> int: - return self.port or self.transport_security.standard_port - - -@dataclass(frozen=True, slots=True) -class MailAttachmentConfig: - base_dir: Path - file_filter: str = "*" - include_subdirs: bool = False - - -@dataclass -class MailEntry: - field_config: FieldConfiguration - is_active: bool = True - from_recipient: Recipient | None = None - to: RecipientList = field(default_factory=RecipientList) - cc: RecipientList = field(default_factory=RecipientList) - bcc: RecipientList = field(default_factory=RecipientList) - combine_to: bool = True - combine_cc: bool = True - combine_bcc: bool = True - attachment_configs: list[MailAttachmentConfig] = field(default_factory=list) - combine_attachments: bool = True - field_contents: FieldContents = field(init=False) - - def __post_init__(self) -> None: - self.field_contents = FieldContents(self.field_config) - - def add_to(self, recipient: Recipient) -> "MailEntry": - self.to.add_recipient(recipient) - return self - - def add_cc(self, recipient: Recipient) -> "MailEntry": - self.cc.add_recipient(recipient) - return self - - def add_bcc(self, recipient: Recipient) -> "MailEntry": - self.bcc.add_recipient(recipient) - return self - - def no_combine_to(self) -> "MailEntry": - self.combine_to = False - return self - - def combine_to_recipients(self) -> "MailEntry": - self.combine_to = True - return self - - def no_combine_attachments(self) -> "MailEntry": - self.combine_attachments = False - return self - - def combine_attachments_with_global(self) -> "MailEntry": - self.combine_attachments = True - return self - - def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry": - self.attachment_configs.append(config) - return self - - def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry": - if not self.field_contents.set_field_content_for_name(name, value): - raise KeyError(f"unknown field: {name}") - return self - - def get_field_content_from_name(self, name: str) -> Field: - return self.field_contents.get_field_content_from_name(name) - - -@dataclass -class MailCampaign: - mail_server_settings: MailServerSettings | None = None - global_from: Recipient | None = None - global_to: RecipientList = field(default_factory=RecipientList) - global_cc: RecipientList = field(default_factory=RecipientList) - global_bcc: RecipientList = field(default_factory=RecipientList) - individual_from: bool = False - individual_to: bool = False - individual_cc: bool = False - individual_bcc: bool = False - base_attachment_path: Path = Path(".") - global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list) - individual_attachments: bool = False - send_without_attachments: bool = True - field_config: FieldConfiguration = field(default_factory=FieldConfiguration) - field_contents: FieldContents = field(init=False) - subject_template: MailTemplate = field(default_factory=MailTemplate) - mail_template: MailTemplate = field(default_factory=MailTemplate) - mail_entries: list[MailEntry] = field(default_factory=list) - - def __post_init__(self) -> None: - self.field_contents = FieldContents(self.field_config) - - @classmethod - def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign": - return cls(mail_server_settings=settings) - - def add_field(self, name: str, field_type) -> "MailCampaign": - from .fields import FieldDescription - self.field_config.add_field_at_end(FieldDescription(name, field_type)) - self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type] - for entry in self.mail_entries: - entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type] - return self - - def set_from(self, recipient: Recipient) -> "MailCampaign": - self.global_from = recipient - return self - - def add_to(self, recipient: Recipient) -> "MailCampaign": - self.global_to.add_recipient(recipient) - return self - - def allow_individual_to(self) -> "MailCampaign": - self.individual_to = True - return self - - def disallow_individual_to(self) -> "MailCampaign": - self.individual_to = False - return self - - def allow_individual_attachments(self) -> "MailCampaign": - self.individual_attachments = True - return self - - def disallow_individual_attachments(self) -> "MailCampaign": - self.individual_attachments = False - return self - - def dont_send_without_attachments(self) -> "MailCampaign": - self.send_without_attachments = False - return self - - def send_without_attachments_allowed(self) -> "MailCampaign": - self.send_without_attachments = True - return self - - def add_new_mail_entry(self) -> MailEntry: - entry = MailEntry(self.field_config) - self.mail_entries.append(entry) - return entry - - def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign": - if not self.field_contents.set_field_content_for_name(name, value): - raise KeyError(f"unknown field: {name}") - return self - - def get_field_content_from_name(self, name: str) -> Field: - return self.field_contents.get_field_content_from_name(name) - - def all_recipients_for(self, entry: MailEntry) -> list[Recipient]: - recipients: list[Recipient] = [] - if not self.individual_to or entry.combine_to: - recipients.extend(self.global_to.recipients) - if not self.individual_cc or entry.combine_cc: - recipients.extend(self.global_cc.recipients) - if not self.individual_bcc or entry.combine_bcc: - recipients.extend(self.global_bcc.recipients) - if self.individual_to: - recipients.extend(entry.to.recipients) - if self.individual_cc: - recipients.extend(entry.cc.recipients) - if self.individual_bcc: - recipients.extend(entry.bcc.recipients) - return recipients diff --git a/src/govoplan_campaign/backend/domain/fields.py b/src/govoplan_campaign/backend/domain/fields.py deleted file mode 100644 index 5ed29fb..0000000 --- a/src/govoplan_campaign/backend/domain/fields.py +++ /dev/null @@ -1,126 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import date, datetime -from enum import StrEnum -from typing import Any - - -class FieldType(StrEnum): - STRING = "string" - INTEGER = "integer" - DOUBLE = "double" - DATE = "date" - PASSWORD = "password" - - -@dataclass(slots=True) -class FieldDescription: - name: str - type: FieldType = FieldType.STRING - can_override: bool = True - - -@dataclass(slots=True) -class Field: - content: Any - - @classmethod - def with_content(cls, content: Any) -> "Field": - if content is None: - raise ValueError("content must not be None") - return cls(content=content) - - @property - def type(self) -> FieldType: - if isinstance(self.content, bool): - return FieldType.STRING - if isinstance(self.content, int): - return FieldType.INTEGER - if isinstance(self.content, float): - return FieldType.DOUBLE - if isinstance(self.content, (date, datetime)): - return FieldType.DATE - if isinstance(self.content, (bytes, bytearray)): - return FieldType.PASSWORD - return FieldType.STRING - - def as_string(self) -> str: - if isinstance(self.content, (bytes, bytearray)): - return self.content.decode("utf-8") - if isinstance(self.content, (date, datetime)): - return self.content.isoformat() - return str(self.content) - - -@dataclass -class FieldConfiguration: - fields: list[FieldDescription] = field(default_factory=list) - - def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration": - return self.add_field_at_position(len(self.fields), field_description) - - def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration": - return self.add_field_at_position(0, field_description) - - def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration": - if self.has_field(field_description.name): - raise ValueError(f"field already exists: {field_description.name}") - position = max(0, min(position, len(self.fields))) - self.fields.insert(position, field_description) - return self - - def has_field(self, name: str) -> bool: - return any(f.name == name for f in self.fields) - - def get_field_description(self, name: str) -> FieldDescription | None: - return next((f for f in self.fields if f.name == name), None) - - def get_field_names(self) -> list[str]: - return [f.name for f in self.fields] - - -@dataclass -class FieldContents: - field_config: FieldConfiguration - field_map: dict[str, Field] = field(default_factory=dict) - - def __post_init__(self) -> None: - for field_description in self.field_config.fields: - self.ensure_field(field_description) - - def ensure_field(self, field_description: FieldDescription) -> None: - if field_description.name in self.field_map: - return - match field_description.type: - case FieldType.INTEGER: - value = 0 - case FieldType.DOUBLE: - value = 0.0 - case FieldType.DATE: - value = date.today() - case FieldType.PASSWORD: - value = b"" - case _: - value = "" - self.field_map[field_description.name] = Field.with_content(value) - - def get_field_content_from_name(self, name: str) -> Field: - try: - return self.field_map[name] - except KeyError as exc: - raise KeyError(f"unknown field: {name}") from exc - - def set_field_content_for_name(self, name: str, value: Field | Any) -> bool: - if name not in self.field_map: - return False - if not isinstance(value, Field): - value = Field.with_content(value) - expected = self.field_map[name].type - if expected != value.type and expected != FieldType.PASSWORD: - raise TypeError(f"field {name!r} expects {expected}, got {value.type}") - self.field_map[name] = value - return True - - def as_value_map(self, prefix: str) -> dict[str, str]: - return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()} diff --git a/src/govoplan_campaign/backend/domain/queue.py b/src/govoplan_campaign/backend/domain/queue.py deleted file mode 100644 index aad26fe..0000000 --- a/src/govoplan_campaign/backend/domain/queue.py +++ /dev/null @@ -1,29 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from email.message import EmailMessage -from typing import Iterator - - -@dataclass -class MailQueue: - messages: list[EmailMessage] = field(default_factory=list) - - def add_mail(self, message: EmailMessage) -> None: - self.messages.append(message) - - def remove_mail(self, message: EmailMessage) -> bool: - if message in self.messages: - self.messages.remove(message) - return True - return False - - @property - def mail_count(self) -> int: - return len(self.messages) - - def is_empty(self) -> bool: - return not self.messages - - def __iter__(self) -> Iterator[EmailMessage]: - return iter(self.messages) diff --git a/src/govoplan_campaign/backend/domain/recipients.py b/src/govoplan_campaign/backend/domain/recipients.py deleted file mode 100644 index 790b00b..0000000 --- a/src/govoplan_campaign/backend/domain/recipients.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from email.utils import formataddr -from enum import StrEnum - - -class RecipientType(StrEnum): - TO = "to" - CC = "cc" - BCC = "bcc" - - -@dataclass(frozen=True, slots=True) -class Recipient: - address: str - name: str | None = None - type: RecipientType = RecipientType.TO - - def formatted(self) -> str: - return formataddr((self.name or self.address, self.address)) - - -@dataclass -class RecipientList: - recipients: list[Recipient] = field(default_factory=list) - - def add_recipient(self, recipient: Recipient) -> "RecipientList": - if recipient not in self.recipients: - self.recipients.append(recipient) - return self - - def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList": - for recipient in recipients: - self.add_recipient(recipient) - return self - - def clear_all_recipients(self) -> "RecipientList": - self.recipients.clear() - return self - - def by_type(self, recipient_type: RecipientType) -> list[Recipient]: - return [r for r in self.recipients if r.type == recipient_type] diff --git a/src/govoplan_campaign/backend/domain/template.py b/src/govoplan_campaign/backend/domain/template.py deleted file mode 100644 index 3055dd0..0000000 --- a/src/govoplan_campaign/backend/domain/template.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass - -_FIELD_PATTERN = re.compile(r"(? "MailTemplate": - self.template_string = template - return self - - def get_used_fields(self) -> set[str]: - return set(_FIELD_PATTERN.findall(self.template_string)) - - def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str: - def replace(match: re.Match[str]) -> str: - key = match.group(1) - if key in values: - return values[key] - return match.group(0) if keep_missing else "" - - rendered = _FIELD_PATTERN.sub(replace, self.template_string) - return rendered.replace(r"\${", "${").replace(r"\}", "}") diff --git a/src/govoplan_campaign/backend/integrations.py b/src/govoplan_campaign/backend/integrations.py new file mode 100644 index 0000000..16af67e --- /dev/null +++ b/src/govoplan_campaign/backend/integrations.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import json +import shutil +import tempfile +from contextlib import contextmanager +from pathlib import Path +from typing import Any, Iterator + +from govoplan_campaign.backend.runtime import capability + + +FILES_CAPABILITY = "files.campaign_attachments" +MAIL_CAPABILITY = "mail.campaign_delivery" + + +class OptionalModuleUnavailable(RuntimeError): + pass + + +class SmtpConfigurationError(RuntimeError): + pass + + +class SmtpSendError(RuntimeError): + def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False) -> None: + super().__init__(message) + self.temporary = temporary + self.outcome_unknown = outcome_unknown + + +class ImapConfigurationError(RuntimeError): + pass + + +class ImapAppendError(RuntimeError): + pass + + +class MailProfileError(OptionalModuleUnavailable): + pass + + +class _PreparedCampaignSnapshot: + def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None: + self._directory = directory + self.path = path + self.raw_json = raw_json + self.managed_files_by_local_path: dict[str, Any] = {} + self.shared_assets: list[Any] = [] + + def cleanup(self) -> None: + shutil.rmtree(self._directory, ignore_errors=True) + + +class FilesCampaignIntegration: + def __init__(self, delegate: Any | None = None) -> None: + self._delegate = delegate + + @property + def available(self) -> bool: + return self._delegate is not None + + @contextmanager + def prepared_campaign_snapshot(self, *args: Any, **kwargs: Any) -> Iterator[Any]: + if self._delegate is not None: + with self._delegate.prepared_campaign_snapshot(*args, **kwargs) as prepared: + yield prepared + return + + raw_json = kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {} + prefix = str(kwargs.get("prefix") or "govoplan-campaign-") + directory = Path(tempfile.mkdtemp(prefix=prefix)) + snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json) + snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8") + try: + yield snapshot + finally: + snapshot.cleanup() + + def managed_match_payloads(self, matches: Any, managed_files_by_local_path: dict[str, Any]) -> list[dict[str, Any]]: + if self._delegate is None: + return [] + return self._delegate.managed_match_payloads(matches, managed_files_by_local_path) + + def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]: + if self._delegate is not None: + return self._delegate.public_attachment_summary_payload(attachment) + if hasattr(attachment, "model_dump"): + return attachment.model_dump(mode="json") + if isinstance(attachment, dict): + return dict(attachment) + return {"path": str(attachment)} + + def annotate_built_messages_with_managed_files(self, built_messages: Any, managed_files_by_local_path: dict[str, Any]) -> None: + if self._delegate is not None: + self._delegate.annotate_built_messages_with_managed_files(built_messages, managed_files_by_local_path) + + def record_campaign_attachment_uses_for_jobs(self, session: Any, jobs: Any, *, stage: str) -> None: + if self._delegate is not None: + self._delegate.record_campaign_attachment_uses_for_jobs(session, jobs, stage=stage) + + def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]: + if self._delegate is None: + raise OptionalModuleUnavailable("Files module is not available") + return self._delegate.current_version_and_blob(session, asset) + + def mark_job_attachment_uses_sent(self, session: Any, job: Any) -> None: + if self._delegate is not None: + self._delegate.mark_job_attachment_uses_sent(session, job) + + +class MailCampaignIntegration: + def __init__(self, delegate: Any | None = None) -> None: + self._delegate = delegate + if delegate is not None: + self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError) + + MailProfileError = MailProfileError + SmtpConfigurationError = SmtpConfigurationError + SmtpSendError = SmtpSendError + ImapConfigurationError = ImapConfigurationError + ImapAppendError = ImapAppendError + + @property + def available(self) -> bool: + return self._delegate is not None + + def _require(self) -> Any: + if self._delegate is None: + raise MailProfileError("Mail module is not available") + return self._delegate + + def materialize_campaign_mail_profile_config(self, session: Any, **kwargs: Any) -> dict[str, Any]: + if self._delegate is None: + raw_json = kwargs.get("raw_json") + if self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {}): + raise MailProfileError("Campaign mail-server profiles require the mail module") + return dict(raw_json) if isinstance(raw_json, dict) else {} + try: + return self._delegate.materialize_campaign_mail_profile_config(session, **kwargs) + except getattr(self._delegate, "MailProfileError", MailProfileError) as exc: + raise MailProfileError(str(exc)) from exc + + def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None: + if self._delegate is None: + raw_json = kwargs.get("raw_json") + profile_id = self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {}) + if profile_id: + raise MailProfileError("Campaign mail-server profiles require the mail module") + return None + try: + return self._delegate.assert_campaign_mail_policy_allows_json(session, **kwargs) + except getattr(self._delegate, "MailProfileError", MailProfileError) as exc: + raise MailProfileError(str(exc)) from exc + + def assert_mail_policy_allows_send(self, session: Any, **kwargs: Any) -> None: + delegate = self._require() + try: + return delegate.assert_mail_policy_allows_send(session, **kwargs) + except getattr(delegate, "MailProfileError", MailProfileError) as exc: + raise MailProfileError(str(exc)) from exc + + def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None: + if self._delegate is not None: + return self._delegate.mail_profile_id_from_campaign_json(raw_json) + server = raw_json.get("server") if isinstance(raw_json, dict) else None + profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None + if profile_id is None and isinstance(server, dict): + profile_id = server.get("profile_id") + return str(profile_id).strip() if profile_id else None + + def ensure_mail_profile_allowed_for_campaign(self, session: Any, **kwargs: Any) -> Any: + delegate = self._require() + try: + return delegate.ensure_mail_profile_allowed_for_campaign(session, **kwargs) + except getattr(delegate, "MailProfileError", MailProfileError) as exc: + raise MailProfileError(str(exc)) from exc + + def smtp_config_from_profile(self, profile: Any) -> Any: + return self._require().smtp_config_from_profile(profile) + + def imap_config_from_profile(self, profile: Any) -> Any: + return self._require().imap_config_from_profile(profile) + + def effective_profile_credentials_inherited(self, session: Any, **kwargs: Any) -> bool: + return self._require().effective_profile_credentials_inherited(session, **kwargs) + + def apply_campaign_credentials(self, profile_payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]: + return self._require().apply_campaign_credentials(profile_payload, server, protocol) + + def wait_for_rate_limit(self, **kwargs: Any) -> None: + if self._delegate is None: + return None + return self._delegate.wait_for_rate_limit(**kwargs) + + def send_email_bytes(self, *args: Any, **kwargs: Any) -> Any: + delegate = self._require() + try: + return delegate.send_email_bytes(*args, **kwargs) + except getattr(delegate, "SmtpSendError", SmtpSendError) as exc: + raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc + except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc: + raise SmtpConfigurationError(str(exc)) from exc + + def append_message_to_sent(self, *args: Any, **kwargs: Any) -> Any: + delegate = self._require() + try: + return delegate.append_message_to_sent(*args, **kwargs) + except getattr(delegate, "ImapAppendError", ImapAppendError) as exc: + raise ImapAppendError(str(exc)) from exc + except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc: + raise ImapConfigurationError(str(exc)) from exc + + def send_email_message(self, *args: Any, **kwargs: Any) -> Any: + delegate = self._require() + try: + return delegate.send_email_message(*args, **kwargs) + except getattr(delegate, "SmtpSendError", SmtpSendError) as exc: + raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc + except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc: + raise SmtpConfigurationError(str(exc)) from exc + + +def files_integration() -> FilesCampaignIntegration: + return FilesCampaignIntegration(capability(FILES_CAPABILITY)) + + +def mail_integration() -> MailCampaignIntegration: + return MailCampaignIntegration(capability(MAIL_CAPABILITY)) diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 948db1f..4bba8d6 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -98,7 +98,9 @@ ROLE_TEMPLATES = ( def _campaigns_router(context: ModuleContext): - del context + from govoplan_campaign.backend.runtime import configure_runtime + + configure_runtime(registry=context.registry, settings=context.settings) from govoplan_campaign.backend.router import router return router @@ -108,8 +110,8 @@ manifest = ModuleManifest( id="campaigns", name="Campaigns", version="1.0.0", - dependencies=("access", "files", "mail"), - optional_dependencies=(), + dependencies=("access",), + optional_dependencies=("files", "mail"), permissions=PERMISSIONS, route_factory=_campaigns_router, role_templates=ROLE_TEMPLATES, diff --git a/src/govoplan_campaign/backend/messages/builder.py b/src/govoplan_campaign/backend/messages/builder.py index 85605d7..8756fad 100644 --- a/src/govoplan_campaign/backend/messages/builder.py +++ b/src/govoplan_campaign/backend/messages/builder.py @@ -3,6 +3,7 @@ from __future__ import annotations import mimetypes import re import tempfile +import time from dataclasses import dataclass from email.message import EmailMessage from email.utils import make_msgid, formatdate @@ -10,6 +11,7 @@ from pathlib import Path from typing import Any, Iterable from govoplan_campaign.backend.attachments.resolver import ( + AttachmentMatchIndex, AttachmentMatchStatus, EntryAttachmentResolution, MessageAttachmentStatus, @@ -27,6 +29,7 @@ from govoplan_campaign.backend.campaign.models import ( MissingAddressBehavior, RecipientConfig, SendStatus, + TemplateBodyMode, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, @@ -144,6 +147,13 @@ def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> t return template.subject or "", template.text, template.html +def _template_body_mode(config: CampaignConfig) -> str: + mode = config.template.body_mode + if isinstance(mode, TemplateBodyMode): + return mode.value + return str(mode or TemplateBodyMode.BOTH.value) + + def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue: severity = "error" if behavior == "block" else "warning" return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source) @@ -181,6 +191,10 @@ def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[Message zip_mode=attachment.zip_mode.value, zip_archive_id=attachment.zip_archive_id, zip_filename=attachment.zip_filename, + message_filename_template=attachment.message_filename_template, + zip_entry_name_template=attachment.zip_entry_name_template, + message_filenames=attachment.message_filenames, + zip_entry_names=attachment.zip_entry_names, base_path_name=attachment.base_path_name, base_path=attachment.base_path, file_filter=attachment.file_filter, @@ -267,18 +281,6 @@ def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_i return filename if filename.lower().endswith(".zip") else f"{filename}.zip" -def _deduplicated_paths(paths: list[Path]) -> list[Path]: - unique: list[Path] = [] - seen: set[str] = set() - for path in paths: - key = str(path.resolve()) - if key in seen: - continue - seen.add(key) - unique.append(path) - return unique - - def _unique_attachment_filename(filename: str, used: set[str]) -> str: candidate = filename path = Path(filename) @@ -290,6 +292,59 @@ def _unique_attachment_filename(filename: str, used: set[str]) -> str: return candidate +def _deduplicated_archive_members(members: list[tuple[Path, str]]) -> list[tuple[Path, str]]: + unique: list[tuple[Path, str]] = [] + seen: set[str] = set() + for path, archive_name in members: + key = str(path.resolve()) + if key in seen: + continue + seen.add(key) + unique.append((path, archive_name)) + return unique + + +def _attachment_filename_values( + *, + values: dict[str, Any], + attachment: ResolvedAttachment, + path: Path, + position: int, +) -> dict[str, Any]: + filename_values = dict(values) + filename_values.update({ + "file.name": path.name, + "file.stem": path.stem, + "file.suffix": path.suffix, + "file.ext": path.suffix[1:] if path.suffix.startswith(".") else path.suffix, + "file.index": position, + "attachment.id": attachment.attachment_id or "", + "attachment.label": attachment.label or "", + }) + return filename_values + + +def _render_attachment_filename( + *, + template: str | None, + attachment: ResolvedAttachment, + path: Path, + values: dict[str, Any], + position: int, +) -> str: + if not template: + return path.name + rendered = _render_template( + template, + _attachment_filename_values(values=values, attachment=attachment, path=path, position=position), + keep_missing=False, + ) + filename = _safe_filename(rendered, path.name) + if path.suffix and not Path(filename).suffix: + filename = f"{filename}{path.suffix}" + return filename + + def _attach_files( *, message: EmailMessage, @@ -301,26 +356,50 @@ def _attach_files( work_dir: Path, ) -> int: attached_count = 0 - archive_members: dict[str, list[Path]] = {} + archive_members: dict[str, list[tuple[Path, str]]] = {} archive_attachments: dict[str, list[ResolvedAttachment]] = {} used_message_filenames: set[str] = set() + used_zip_member_filenames: dict[str, set[str]] = {} + + for attachment in resolution.attachments: + attachment.message_filenames = [] + attachment.zip_entry_names = [] for attachment in resolution.attachments: if attachment.status != AttachmentMatchStatus.OK or not attachment.matches: continue match_paths = [Path(match) for match in attachment.matches] if attachment.zip_enabled and attachment.zip_archive_id: - archive_members.setdefault(attachment.zip_archive_id, []).extend(match_paths) + used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, set()) + for position, path in enumerate(match_paths, start=1): + requested = _render_attachment_filename( + template=attachment.zip_entry_name_template, + attachment=attachment, + path=path, + values=values, + position=position, + ) + archive_name = _unique_attachment_filename(requested, used_archive_names) + archive_members.setdefault(attachment.zip_archive_id, []).append((path, archive_name)) + attachment.zip_entry_names.append(archive_name) archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment) continue - for path in match_paths: - filename = _unique_attachment_filename(path.name, used_message_filenames) + for position, path in enumerate(match_paths, start=1): + requested = _render_attachment_filename( + template=attachment.message_filename_template, + attachment=attachment, + path=path, + values=values, + position=position, + ) + filename = _unique_attachment_filename(requested, used_message_filenames) + attachment.message_filenames.append(filename) data, maintype, subtype = _attachment_bytes(path) message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) attached_count += 1 for archive in config.attachments.zip.archives: - members = _deduplicated_paths(archive_members.get(archive.id, [])) + members = _deduplicated_archive_members(archive_members.get(archive.id, [])) if not members: continue filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames) @@ -361,8 +440,9 @@ def build_entry_message( output_dir: Path | None = None, write_eml: bool = False, 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) + 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 @@ -412,17 +492,19 @@ def build_entry_message( 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 = sorted( - _find_unresolved_placeholders(subject) - | _find_unresolved_placeholders(text_body) - | _find_unresolved_placeholders(html_body) - ) + 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( @@ -458,9 +540,14 @@ def build_entry_message( # bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender. message["Subject"] = subject - if html_body is not None: + 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 "") - message.add_alternative(html_body, subtype="html") + 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 "") @@ -526,6 +613,7 @@ def _unsent_attachment_issues( config: CampaignConfig, campaign_file: str | Path, built_messages: list[BuiltMessage], + attachment_match_index: AttachmentMatchIndex | None = None, ) -> list[MessageIssue]: behavior = config.validation_policy.unsent_attachment_files.value if behavior == Behavior.CONTINUE.value: @@ -545,7 +633,10 @@ def _unsent_attachment_issues( directory = _resolve(campaign_file, base_path.path) if not directory.exists() or not directory.is_dir(): continue - all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file()) + if attachment_match_index is not None: + all_files = sorted(path.resolve() for path in attachment_match_index.iter_files(directory, recursive=True)) + else: + all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file()) unsent = [path for path in all_files if path not in matched_files] if not unsent: continue @@ -587,6 +678,8 @@ def build_campaign_messages( entries = load_campaign_entries(config, campaign_file=campaign_path) output_path = Path(output_dir).resolve() if output_dir is not None else None + started = time.perf_counter() + attachment_match_index = AttachmentMatchIndex() with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp: work_dir = output_path or Path(tmp) built_messages = [ @@ -598,15 +691,22 @@ def build_campaign_messages( output_dir=output_path, write_eml=write_eml, work_dir=work_dir, + attachment_match_index=attachment_match_index, ) for index, entry in enumerate(entries, start=1) if entry.active ] _apply_campaign_level_issues( built_messages, - _unsent_attachment_issues(config=config, campaign_file=campaign_path, built_messages=built_messages), + _unsent_attachment_issues( + config=config, + campaign_file=campaign_path, + built_messages=built_messages, + attachment_match_index=attachment_match_index, + ), ) + rules_resolved = sum(len(built.draft.attachments) for built in built_messages) report = CampaignBuildReport( campaign_id=config.campaign.id, campaign_name=config.campaign.name, @@ -614,5 +714,9 @@ def build_campaign_messages( entries_count=len(entries), inactive_entries_count=sum(1 for entry in entries if not entry.active), messages=[built.draft for built in built_messages], + attachment_resolution_profile=attachment_match_index.stats( + duration_ms=(time.perf_counter() - started) * 1000, + rules_resolved=rules_resolved, + ), ) return CampaignBuildResult(report=report, built_messages=built_messages) diff --git a/src/govoplan_campaign/backend/messages/models.py b/src/govoplan_campaign/backend/messages/models.py index bec345a..cdb653c 100644 --- a/src/govoplan_campaign/backend/messages/models.py +++ b/src/govoplan_campaign/backend/messages/models.py @@ -55,6 +55,10 @@ class MessageAttachmentSummary(BaseModel): zip_mode: str = "inherit" zip_archive_id: str | None = None zip_filename: str | None = None + message_filename_template: str | None = None + zip_entry_name_template: str | None = None + message_filenames: list[str] = Field(default_factory=list) + zip_entry_names: list[str] = Field(default_factory=list) base_path_name: str | None = None base_path: str | None = None file_filter: str @@ -109,6 +113,7 @@ class CampaignBuildReport(BaseModel): entries_count: int inactive_entries_count: int = 0 messages: list[MessageDraft] = Field(default_factory=list) + attachment_resolution_profile: dict[str, object] = Field(default_factory=dict) @property def built_count(self) -> int: diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py index 704311f..cdc43d5 100644 --- a/src/govoplan_campaign/backend/persistence/campaigns.py +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -31,13 +31,7 @@ from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.models import MessageDraft from govoplan_campaign.backend.sending.execution import create_execution_snapshot from govoplan_campaign.backend.campaign.models import CampaignConfig -from govoplan_mail.backend.mail_profiles import materialize_campaign_mail_profile_config -from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs -from govoplan_files.backend.storage.campaign_attachments import ( - annotate_built_messages_with_managed_files, - prepared_campaign_snapshot, - public_attachment_summary_payload, -) +from govoplan_campaign.backend.integrations import files_integration, mail_integration RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime" CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots" @@ -64,7 +58,7 @@ def load_campaign_config_from_json( owner_user_id: str | None = None, owner_group_id: str | None = None, ) -> CampaignConfig: - materialized = materialize_campaign_mail_profile_config( + materialized = mail_integration().materialize_campaign_mail_profile_config( session, tenant_id=tenant_id, raw_json=raw_json, @@ -274,7 +268,8 @@ def validate_campaign_version( raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.") if check_files: - with prepared_campaign_snapshot( + files = files_integration() + with files.prepared_campaign_snapshot( session, tenant_id=tenant_id, campaign_id=campaign.id, @@ -379,7 +374,7 @@ def _job_from_message( "bounce_to": [item.model_dump(mode="json") for item in message.bounce_to], "disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to], }, - resolved_attachments=[public_attachment_summary_payload(item) for item in message.attachments], + resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments], issues_snapshot=[item.model_dump(mode="json") for item in message.issues], last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None, ) @@ -405,7 +400,8 @@ def build_campaign_version( _ensure_version_validated_and_locked(version) output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id - with prepared_campaign_snapshot( + files = files_integration() + with files.prepared_campaign_snapshot( session, tenant_id=tenant_id, campaign_id=campaign.id, @@ -416,11 +412,11 @@ def build_campaign_version( managed_raw = load_campaign_json(prepared.path) managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id) result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml) - annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path) + files.annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path) report_json = result.report.model_dump(mode="json", by_alias=True) for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False): if isinstance(message_payload, dict): - message_payload["attachments"] = [public_attachment_summary_payload(item) for item in message.attachments] + message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments] report_json["built_at"] = datetime.now(UTC).isoformat() report_json["build_token"] = uuid4().hex report_json.update({ @@ -459,17 +455,18 @@ def build_campaign_version( # records in bulk. This avoids one flush plus several metadata queries per # recipient for large campaigns. session.flush() - record_campaign_attachment_uses_for_jobs( + files.record_campaign_attachment_uses_for_jobs( session, [job for job, _message in job_build_pairs], stage="built", ) - if not managed_config.server.smtp: + runtime_smtp = managed_config.server.runtime_smtp_config() + if not runtime_smtp: raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created") execution_snapshot, execution_snapshot_hash = create_execution_snapshot( version, - smtp=managed_config.server.smtp, - imap=managed_config.server.imap, + smtp=runtime_smtp, + imap=managed_config.server.runtime_imap_config(), delivery=managed_config.delivery, jobs=[job for job, _message in job_build_pairs], build_summary=report_json, diff --git a/src/govoplan_campaign/backend/persistence/versions.py b/src/govoplan_campaign/backend/persistence/versions.py index 59e3eac..f263443 100644 --- a/src/govoplan_campaign/backend/persistence/versions.py +++ b/src/govoplan_campaign/backend/persistence/versions.py @@ -18,7 +18,7 @@ from govoplan_campaign.backend.db.models import ( JobSendStatus, ) from govoplan_campaign.backend.sending.execution import clear_execution_snapshot -from govoplan_mail.backend.mail_profiles import assert_campaign_mail_policy_allows_json +from govoplan_campaign.backend.integrations import mail_integration from govoplan_campaign.backend.persistence.campaigns import ( CampaignPersistenceError, _next_version_number, @@ -77,19 +77,18 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non "smtp": { "host": "", "port": 587, - "username": "", - "password": "", "security": "starttls", }, "imap": { - "enabled": False, "host": "", "port": 993, - "username": "", - "password": "", "security": "tls", "sent_folder": "auto", }, + "credentials": { + "smtp": {"username": "", "password": ""}, + "imap": {"username": "", "password": ""}, + }, }, "recipients": { "from": [], @@ -339,7 +338,7 @@ def fork_campaign_version_for_edit( base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json) runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json) - assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id) + mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id) new_version = CampaignVersion( campaign_id=campaign.id, @@ -502,7 +501,7 @@ def update_campaign_version( if raw_json is not None: runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json) - assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id) + mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id) version.raw_json = runtime_json version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0")) _apply_campaign_metadata(campaign, runtime_json) @@ -796,9 +795,22 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.") template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {} - if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")): + 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.") - if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict): + 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.") attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {} diff --git a/src/govoplan_campaign/backend/reports/emailing.py b/src/govoplan_campaign/backend/reports/emailing.py index 037c0ca..561ddca 100644 --- a/src/govoplan_campaign/backend/reports/emailing.py +++ b/src/govoplan_campaign/backend/reports/emailing.py @@ -13,7 +13,7 @@ from govoplan_campaign.backend.campaign.loader import load_campaign_config from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv -from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendResult, send_email_message +from govoplan_campaign.backend.integrations import SmtpConfigurationError, mail_integration class CampaignReportEmailError(RuntimeError): @@ -70,8 +70,9 @@ def _load_config(version: CampaignVersion) -> CampaignConfig: def _effective_from(config: CampaignConfig) -> tuple[str, str | None]: if config.recipients.from_: return config.recipients.from_[0].email, config.recipients.from_[0].name - if config.server.smtp and config.server.smtp.username and "@" in config.server.smtp.username: - return config.server.smtp.username, None + smtp_config = config.server.runtime_smtp_config() + if smtp_config and smtp_config.username and "@" in smtp_config.username: + return smtp_config.username, None raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address") @@ -161,7 +162,7 @@ def send_campaign_report_email( version = _selected_version(session, campaign, version_id) config = _load_config(version) - smtp_config: SmtpConfig | None = config.server.smtp + smtp_config: SmtpConfig | None = config.server.runtime_smtp_config() if smtp_config is None: raise SmtpConfigurationError("Campaign has no SMTP configuration") @@ -202,7 +203,7 @@ def send_campaign_report_email( smtp_port=smtp_config.port, ) - result: SmtpSendResult = send_email_message( + result = mail_integration().send_email_message( message, smtp_config=smtp_config, envelope_from=envelope_from, diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index 245464a..f7ffe55 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -28,6 +28,7 @@ from govoplan_campaign.backend.schemas import ( CampaignResponse, CampaignVersionDetailResponse, CampaignVersionResponse, + CampaignWorkspaceResponse, CampaignVersionSetStepRequest, CampaignReviewStateRequest, CampaignVersionUpdateRequest, @@ -50,8 +51,7 @@ from govoplan_campaign.backend.persistence.campaigns import ( load_campaign_config_from_json, validate_campaign_version, ) -from govoplan_files.backend.storage.files import current_version_and_blob -from govoplan_files.backend.storage.campaign_attachments import managed_match_payloads, prepared_campaign_snapshot +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.attachments.resolver import resolve_campaign_attachments from govoplan_core.security.time import utc_now @@ -355,6 +355,62 @@ def get_campaign( return CampaignResponse.model_validate(_get_campaign_for_principal(session, campaign_id, principal)) +@router.get("/{campaign_id}/workspace", response_model=CampaignWorkspaceResponse) +def get_campaign_workspace( + campaign_id: str, + version_id: str | None = None, + include_current_version: bool = True, + include_summary: bool = False, + include_versions: bool = True, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal) + + versions: list[CampaignVersion] = [] + if include_versions or include_current_version: + versions = ( + session.query(CampaignVersion) + .filter(CampaignVersion.campaign_id == campaign.id) + .order_by(CampaignVersion.version_number.desc()) + .all() + ) + + selected_version_id = version_id or campaign.current_version_id or (versions[0].id if versions else None) + current_version: CampaignVersion | None = None + if include_current_version and selected_version_id: + _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() + ) + + summary_payload: dict[str, object] | None = None + if include_summary: + 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 + + return CampaignWorkspaceResponse( + campaign=CampaignResponse.model_validate(campaign), + versions=[CampaignVersionResponse.model_validate(item) for item in versions] if include_versions else [], + current_version=CampaignVersionDetailResponse.model_validate(current_version) if current_version is not None else None, + summary=summary_payload, + selected_version_id=selected_version_id, + ) + + @router.put("/{campaign_id}", response_model=CampaignResponse) def update_campaign_metadata_endpoint( campaign_id: str, @@ -1811,6 +1867,7 @@ def append_sent( tenant_id=principal.tenant_id, campaign_id=campaign_id, enqueue_celery=payload.enqueue_celery, + run_inline=payload.run_inline, dry_run=payload.dry_run, ) audit_from_principal( @@ -1841,7 +1898,7 @@ class CampaignAttachmentPreviewResponse(BaseModel): def _file_preview(session: Session, asset) -> dict[str, object]: - version, blob = current_version_and_blob(session, asset) + version, blob = files_integration().current_version_and_blob(session, asset) return { "id": asset.id, "version_id": version.id, @@ -1876,7 +1933,8 @@ def preview_campaign_attachments( raw = raw if isinstance(raw, dict) else {} _require_mail_profile_use_if_needed(principal, raw) - with prepared_campaign_snapshot( + files = files_integration() + with files.prepared_campaign_snapshot( session, tenant_id=principal.tenant_id, campaign_id=campaign.id, @@ -1892,7 +1950,7 @@ def preview_campaign_attachments( for entry in report.entries: for attachment in entry.attachments: - managed_matches = managed_match_payloads(attachment.matches, prepared.managed_files_by_local_path) + managed_matches = files.managed_match_payloads(attachment.matches, prepared.managed_files_by_local_path) matched_asset_ids.update(str(item["asset_id"]) for item in managed_matches) matches: list[dict[str, object]] = [ { diff --git a/src/govoplan_campaign/backend/runtime.py b/src/govoplan_campaign/backend/runtime.py new file mode 100644 index 0000000..9ab4b9d --- /dev/null +++ b/src/govoplan_campaign/backend/runtime.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import Any + +_runtime_registry: object | None = None +_runtime_settings: object | None = None + + +def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None: + global _runtime_registry, _runtime_settings + if registry is not None: + _runtime_registry = registry + if settings is not None: + _runtime_settings = settings + + +def get_registry() -> object | None: + return _runtime_registry + + +def get_settings() -> object | None: + return _runtime_settings + + +def capability(name: str) -> Any | None: + registry = get_registry() + if registry is None or not hasattr(registry, "capability"): + return None + return registry.capability(name) + + +def has_capability(name: str) -> bool: + registry = get_registry() + if registry is None or not hasattr(registry, "has_capability"): + return False + return bool(registry.has_capability(name)) diff --git a/src/govoplan_campaign/backend/schema/campaign.schema.json b/src/govoplan_campaign/backend/schema/campaign.schema.json index 9e23179..d30b5ca 100644 --- a/src/govoplan_campaign/backend/schema/campaign.schema.json +++ b/src/govoplan_campaign/backend/schema/campaign.schema.json @@ -175,6 +175,40 @@ } }, "additionalProperties": false + }, + "credentials": { + "type": "object", + "properties": { + "smtp": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + }, + "imap": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "default": { + "smtp": {}, + "imap": {} + } } }, "additionalProperties": false @@ -301,6 +335,16 @@ "string", "null" ] + }, + "body_mode": { + "type": "string", + "enum": [ + "text", + "html", + "both" + ], + "default": "both", + "description": "Which body parts should be generated for campaign messages." } }, "additionalProperties": false @@ -336,6 +380,16 @@ } }, "additionalProperties": false + }, + "body_mode": { + "type": "string", + "enum": [ + "text", + "html", + "both" + ], + "default": "both", + "description": "Which body parts should be generated for campaign messages." } }, "additionalProperties": false @@ -745,6 +799,22 @@ "string", "null" ] + }, + "message_filename_template": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Optional recipient-rendered filename template used when this rule sends files directly as message attachments. If omitted, the source filename is used." + }, + "zip_entry_name_template": { + "type": [ + "string", + "null" + ], + "default": null, + "description": "Optional recipient-rendered filename template used for this rule's files inside recipient ZIP archives. If omitted, the source filename is used." } }, "additionalProperties": false diff --git a/src/govoplan_campaign/backend/schema/campaign.schema.ui.json b/src/govoplan_campaign/backend/schema/campaign.schema.ui.json new file mode 100644 index 0000000..049c9bb --- /dev/null +++ b/src/govoplan_campaign/backend/schema/campaign.schema.ui.json @@ -0,0 +1,137 @@ +{ + "$schema": "https://govoplan.local/schema/campaign.schema.ui.json", + "$id": "https://govoplan.local/schema/campaign.schema.ui.json", + "schema": "campaign.schema.json", + "version": 1, + "sections": [ + { + "id": "basics", + "label": "Basics", + "path": "/campaign", + "order": 10 + }, + { + "id": "fields", + "label": "Fields", + "path": "/fields", + "order": 20 + }, + { + "id": "global_values", + "label": "Global Values", + "path": "/global_values", + "order": 30 + }, + { + "id": "recipients", + "label": "Recipients", + "path": "/recipients", + "order": 40 + }, + { + "id": "template", + "label": "Template", + "path": "/template", + "order": 50 + }, + { + "id": "attachments", + "label": "Attachments", + "path": "/attachments", + "order": 60 + }, + { + "id": "mail", + "label": "Mail", + "path": "/server", + "order": 70, + "requiresCapability": "mail.campaign_delivery" + }, + { + "id": "delivery", + "label": "Delivery", + "path": "/delivery", + "order": 80 + }, + { + "id": "review", + "label": "Review & Send", + "path": "/review", + "order": 90 + } + ], + "fields": { + "/attachments/global[]/message_filename_template": { + "label": "Direct attachment filename", + "control": "text", + "placeholder": "{{ file.name }}", + "templateContext": [ + "global values", + "recipient fields", + "file.name", + "file.stem", + "file.suffix", + "file.ext", + "file.index", + "attachment.id", + "attachment.label" + ], + "description": "Optional filename template used when files are attached directly to the message." + }, + "/attachments/global[]/zip_entry_name_template": { + "label": "ZIP entry filename", + "control": "text", + "placeholder": "{{ file.name }}", + "templateContext": [ + "global values", + "recipient fields", + "file.name", + "file.stem", + "file.suffix", + "file.ext", + "file.index", + "attachment.id", + "attachment.label" + ], + "description": "Optional filename template used for files inside generated ZIP archives." + }, + "/entries/inline[]/attachments[]/message_filename_template": { + "label": "Direct attachment filename", + "control": "text", + "placeholder": "{{ file.name }}", + "templateContext": [ + "global values", + "recipient fields", + "file.name", + "file.stem", + "file.suffix", + "file.ext", + "file.index", + "attachment.id", + "attachment.label" + ], + "description": "Optional filename template used when files are attached directly to the message." + }, + "/entries/inline[]/attachments[]/zip_entry_name_template": { + "label": "ZIP entry filename", + "control": "text", + "placeholder": "{{ file.name }}", + "templateContext": [ + "global values", + "recipient fields", + "file.name", + "file.stem", + "file.suffix", + "file.ext", + "file.index", + "attachment.id", + "attachment.label" + ], + "description": "Optional filename template used for files inside generated ZIP archives." + } + }, + "capabilities": { + "files.campaign_attachments": "Enable managed file chooser and frozen file-version evidence.", + "mail.campaign_delivery": "Enable mail profile selection, SMTP/IMAP validation, queueing, and delivery." + } +} diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index d3a56f2..0be51a1 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -5,7 +5,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field -from govoplan_mail.backend.config import ImapConfig, SmtpConfig +from govoplan_core.mail.config import ImapConfig, SmtpConfig class CampaignCreateRequest(BaseModel): @@ -137,6 +137,14 @@ class CampaignListResponse(BaseModel): campaigns: list[CampaignResponse] +class CampaignWorkspaceResponse(BaseModel): + campaign: CampaignResponse | None = None + versions: list[CampaignVersionResponse] = Field(default_factory=list) + current_version: CampaignVersionDetailResponse | None = None + summary: dict[str, Any] | None = None + selected_version_id: str | None = None + + class CampaignShareItem(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -241,8 +249,6 @@ class MailSmtpTestRequest(SmtpConfig): class MailImapTestRequest(ImapConfig): """IMAP settings supplied directly from the WebUI mail settings form.""" - enabled: bool = True - @@ -426,6 +432,7 @@ class AppendSentRequest(BaseModel): model_config = ConfigDict(extra="forbid") enqueue_celery: bool = True + run_inline: bool = False dry_run: bool = False diff --git a/src/govoplan_campaign/backend/sending/execution.py b/src/govoplan_campaign/backend/sending/execution.py index 6525a91..aa0f8b5 100644 --- a/src/govoplan_campaign/backend/sending/execution.py +++ b/src/govoplan_campaign/backend/sending/execution.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig -from govoplan_mail.backend.mail_profiles import MailProfileError, apply_campaign_credentials, effective_profile_credentials_inherited, ensure_mail_profile_allowed_for_campaign, imap_config_from_profile, mail_profile_id_from_campaign_json, smtp_config_from_profile +from govoplan_campaign.backend.integrations import MailProfileError, mail_integration SNAPSHOT_VERSION = "3" @@ -83,8 +83,12 @@ def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpCo def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None: server = raw_json.get("server") if isinstance(raw_json, dict) else None - config = server.get(name) if isinstance(server, dict) else None + credentials = server.get("credentials") if isinstance(server, dict) and isinstance(server.get("credentials"), dict) else None + config = credentials.get(name) if isinstance(credentials, dict) and isinstance(credentials.get(name), dict) else None password = config.get("password") if isinstance(config, dict) else None + if password is None: + legacy_config = server.get(name) if isinstance(server, dict) else None + password = legacy_config.get("password") if isinstance(legacy_config, dict) else None if password is None: return None return str(password) @@ -96,14 +100,15 @@ def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any def _profile_for_version(session: Session, version: CampaignVersion): - profile_id = mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {}) + mail = mail_integration() + profile_id = mail.mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {}) if not profile_id: return None campaign = session.get(Campaign, version.campaign_id) if campaign is None: raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution") try: - return ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True) + return mail.ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True) except MailProfileError as exc: raise ExecutionSnapshotError(str(exc)) from exc @@ -117,10 +122,11 @@ def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: Ex campaign = session.get(Campaign, version.campaign_id) if campaign is None: raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution") - profile_payload = smtp_config_from_profile(profile).model_dump(mode="json") - if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"): + mail = mail_integration() + profile_payload = mail.smtp_config_from_profile(profile).model_dump(mode="json") + if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"): return SmtpConfig.model_validate(profile_payload) - return SmtpConfig.model_validate(apply_campaign_credentials(profile_payload, server, "smtp")) + return SmtpConfig.model_validate(mail.apply_campaign_credentials(profile_payload, server, "smtp")) payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp") return SmtpConfig.model_validate(payload) @@ -131,29 +137,31 @@ def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: Ex profile = _profile_for_version(session, version) if profile is None: return None - imap = imap_config_from_profile(profile) + mail = mail_integration() + imap = mail.imap_config_from_profile(profile) if imap is None: return None campaign = session.get(Campaign, version.campaign_id) if campaign is None: raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution") imap_payload = imap.model_dump(mode="json") - if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"): + if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"): return ImapConfig.model_validate(imap_payload) - return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap")) + return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap")) payload = snapshot.imap.model_dump(mode="json") if not payload.get("password"): profile = _profile_for_version(session, version) if profile is not None: - imap = imap_config_from_profile(profile) + mail = mail_integration() + imap = mail.imap_config_from_profile(profile) if imap is not None: campaign = session.get(Campaign, version.campaign_id) if campaign is None: raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution") imap_payload = imap.model_dump(mode="json") - if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"): + if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"): return ImapConfig.model_validate(imap_payload) - return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap")) + return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap")) payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap") return ImapConfig.model_validate(payload) @@ -250,7 +258,8 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe from govoplan_campaign.backend.persistence.campaigns import load_version_config _, _, config = load_version_config(session, version.id) - if not config.server.smtp: + runtime_smtp = config.server.runtime_smtp_config() + if not runtime_smtp: raise ExecutionSnapshotError("Campaign has no SMTP configuration") jobs = ( session.query(CampaignJob) @@ -262,8 +271,8 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery") payload, digest = create_execution_snapshot( version, - smtp=config.server.smtp, - imap=config.server.imap, + smtp=runtime_smtp, + imap=config.server.runtime_imap_config(), delivery=config.delivery, jobs=jobs, build_summary=version.build_summary if isinstance(version.build_summary, dict) else {}, diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py index 2112054..e1d67e1 100644 --- a/src/govoplan_campaign/backend/sending/jobs.py +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -28,11 +28,15 @@ 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_mail.backend.mail_profiles import MailProfileError, assert_mail_policy_allows_send -from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit -from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes -from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent -from govoplan_files.backend.storage.services import mark_job_attachment_uses_sent +from govoplan_campaign.backend.integrations import ( + ImapAppendError, + ImapConfigurationError, + MailProfileError, + SmtpConfigurationError, + SmtpSendError, + files_integration, + mail_integration, +) class QueueingError(RuntimeError): @@ -709,7 +713,7 @@ def reconcile_job_outcome( job.claim_token = None job.last_error = note or "Operator confirmed SMTP acceptance after an uncertain outcome." job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value - mark_job_attachment_uses_sent(session, job) + files_integration().mark_job_attachment_uses_sent(session, job) attempt_status = "reconciled_smtp_accepted" elif decision == "not_sent": job.send_status = JobSendStatus.FAILED_TEMPORARY.value @@ -1054,7 +1058,7 @@ def send_campaign_job( job = session.get(CampaignJob, job.id) assert job is not None - wait_for_rate_limit( + 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, enabled=use_rate_limit, @@ -1062,7 +1066,7 @@ def send_campaign_job( attempt = _record_attempt_start(session, job, claim_token) try: smtp_config = runtime_smtp_config(session, version, snapshot) - assert_mail_policy_allows_send( + mail_integration().assert_mail_policy_allows_send( session, tenant_id=job.tenant_id, campaign_id=job.campaign_id, @@ -1071,7 +1075,7 @@ def send_campaign_job( from_header=_from_header_from_job(job, snapshot), recipients=envelope_recipients, ) - result = send_email_bytes( + result = mail_integration().send_email_bytes( message_bytes, smtp_config=smtp_config, envelope_from=envelope_from, @@ -1098,7 +1102,7 @@ def send_campaign_job( else: job.imap_status = JobImapStatus.NOT_REQUESTED.value job.last_error = refused_warning - mark_job_attachment_uses_sent(session, job) + 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) @@ -1208,9 +1212,9 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) session.commit() return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run) imap_config = runtime_imap_config(session, version, snapshot) - if not imap_config or not imap_config.enabled: + if not imap_config: job.imap_status = JobImapStatus.SKIPPED.value - job.last_error = "IMAP append requested, but the execution snapshot has no enabled IMAP configuration" + job.last_error = "IMAP append requested, but the execution snapshot has no IMAP configuration" session.add(job) session.commit() return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error) @@ -1230,14 +1234,14 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) attempt = _record_imap_attempt_start(session, job) try: - assert_mail_policy_allows_send( + mail_integration().assert_mail_policy_allows_send( session, tenant_id=job.tenant_id, campaign_id=job.campaign_id, smtp=snapshot.smtp, imap=imap_config, ) - result = append_message_to_sent( + result = mail_integration().append_message_to_sent( message_bytes, imap_config=imap_config, folder=None if folder == "auto" else folder, @@ -1246,7 +1250,7 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) attempt.folder = result.folder job.imap_status = JobImapStatus.APPENDED.value job.last_error = None - mark_job_attachment_uses_sent(session, job) + files_integration().mark_job_attachment_uses_sent(session, job) session.add(attempt) session.add(job) session.commit() @@ -1263,7 +1267,15 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) raise -def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True, dry_run: bool = False) -> dict[str, Any]: +def enqueue_pending_imap_appends( + session: Session, + *, + tenant_id: str, + campaign_id: str, + enqueue_celery: bool = True, + run_inline: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) jobs = ( session.query(CampaignJob) @@ -1276,15 +1288,39 @@ def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_i .order_by(CampaignJob.entry_index.asc()) .all() ) - should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run - if should_enqueue: + should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run and not run_inline + results: list[dict[str, Any]] = [] + appended_count = 0 + failed_count = 0 + skipped_count = 0 + if run_inline or dry_run: + for job in jobs: + try: + result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run) + payload = result.as_dict() + results.append(payload) + if result.status == JobImapStatus.APPENDED.value: + appended_count += 1 + elif result.status in {"skipped", "not_requested", "not_sent", "already_appended", "dry_run"}: + skipped_count += 1 + except Exception as exc: # keep processing later jobs and expose per-job details + failed_count += 1 + results.append({"job_id": job.id, "status": "failed", "message": str(exc)}) + elif should_enqueue: for job in jobs: _celery_enqueue_append_sent_job(job.id) + return { "campaign_id": campaign.id, "pending_count": len(jobs), "enqueued_count": len(jobs) if should_enqueue else 0, + "processed_count": len(results) if run_inline and not dry_run else 0, + "appended_count": appended_count, + "failed_count": failed_count, + "skipped_count": skipped_count, "dry_run": dry_run, + "run_inline": run_inline, + "results": results, } def next_retry_delay(snapshot: ExecutionSnapshot, attempt_count: int) -> int: diff --git a/src/govoplan_campaign/backend/services/attachment_matching.py b/src/govoplan_campaign/backend/services/attachment_matching.py deleted file mode 100644 index 379eb79..0000000 --- a/src/govoplan_campaign/backend/services/attachment_matching.py +++ /dev/null @@ -1,13 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from govoplan_campaign.backend.domain.campaign import MailAttachmentConfig - - -def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]: - directory = base_path / config.base_dir - if not directory.exists(): - return [] - iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter) - return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0) diff --git a/src/govoplan_campaign/backend/services/campaign_executor.py b/src/govoplan_campaign/backend/services/campaign_executor.py deleted file mode 100644 index 3020593..0000000 --- a/src/govoplan_campaign/backend/services/campaign_executor.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -import smtplib -from email.message import EmailMessage -from pathlib import Path -from typing import Iterable - -from govoplan_campaign.backend.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity -from govoplan_campaign.backend.domain.queue import MailQueue -from govoplan_campaign.backend.domain.recipients import Recipient, RecipientType -from govoplan_campaign.backend.services.attachment_matching import match_files -from govoplan_campaign.backend.services.zip_service import create_encrypted_zip - - -def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str: - return ", ".join(r.formatted() for r in recipients if r.type == recipient_type) - - -def _recipient_values(recipients: list[Recipient]) -> dict[str, str]: - def rows(recipient_type: RecipientType) -> list[Recipient]: - return [r for r in recipients if r.type == recipient_type] - - def joined(recipient_type: RecipientType, mode: str) -> str: - selected = rows(recipient_type) - if mode == "address": - return ", ".join(r.address for r in selected) - if mode == "name": - return ", ".join(r.name or r.address for r in selected) - return ", ".join(r.formatted() for r in selected) - - return { - "mm_recipients": joined(RecipientType.TO, "formatted"), - "mm_recipients_address": joined(RecipientType.TO, "address"), - "mm_recipients_name": joined(RecipientType.TO, "name"), - "mm_cc": joined(RecipientType.CC, "formatted"), - "mm_cc_address": joined(RecipientType.CC, "address"), - "mm_cc_name": joined(RecipientType.CC, "name"), - "mm_bcc": joined(RecipientType.BCC, "formatted"), - "mm_bcc_address": joined(RecipientType.BCC, "address"), - "mm_bcc_name": joined(RecipientType.BCC, "name"), - } - - -def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]: - paths: list[Path] = [] - if entry.combine_attachments: - for config in campaign.global_attachment_configs: - paths.extend(match_files(campaign.base_attachment_path, config)) - if campaign.individual_attachments: - for config in entry.attachment_configs: - paths.extend(match_files(campaign.base_attachment_path, config)) - return paths - - -def get_not_sent_files(campaign: MailCampaign) -> set[Path]: - """Return files matching campaign attachment configs that are not referenced by any built entry.""" - all_files: set[Path] = set() - used_files: set[Path] = set() - for config in campaign.global_attachment_configs: - all_files.update(match_files(campaign.base_attachment_path, config)) - for entry in campaign.mail_entries: - files = set(_message_attachment_paths(campaign, entry)) - used_files.update(files) - all_files.update(files) - return all_files - used_files - - -def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None: - recipients = campaign.all_recipients_for(entry) - attachments = _message_attachment_paths(campaign, entry) - if not attachments and not campaign.send_without_attachments: - return None - - values = {} - values.update(_recipient_values(recipients)) - values.update(campaign.field_contents.as_value_map("global")) - values.update(entry.field_contents.as_value_map("local")) - - message = EmailMessage() - sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from - if sender: - message["From"] = sender.formatted() - to_header = _recipient_header(recipients, RecipientType.TO) - cc_header = _recipient_header(recipients, RecipientType.CC) - if to_header: - message["To"] = to_header - if cc_header: - message["Cc"] = cc_header - message["Subject"] = campaign.subject_template.apply_values(values) - message.set_content(campaign.mail_template.apply_values(values)) - - attachment_paths = attachments - - if attachments and zip_attachments: - number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments" - password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else "" - zip_path = campaign.base_attachment_path / f"{number}.zip" - attachment_paths = [create_encrypted_zip(zip_path, attachments, password)] - - for attachment_path in attachment_paths: - data = attachment_path.read_bytes() - message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name) - return message - - -def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue: - queue = MailQueue() - for entry in campaign.mail_entries: - if not entry.is_active: - continue - message = build_message(campaign, entry, zip_attachments=zip_attachments) - if message is not None: - queue.add_mail(message) - return queue - - -def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue: - retry_queue = MailQueue() - if settings.transport_security == TransportSecurity.TLS: - smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60) - else: - smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60) - try: - if settings.transport_security == TransportSecurity.STARTTLS: - smtp.starttls() - if settings.username: - smtp.login(settings.username, settings.password) - for message in queue: - try: - smtp.send_message(message) - except Exception: - retry_queue.add_mail(message) - finally: - smtp.quit() - return retry_queue diff --git a/webui/package.json b/webui/package.json index cf06229..a88048a 100644 --- a/webui/package.json +++ b/webui/package.json @@ -13,10 +13,6 @@ }, "./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css" }, - "dependencies": { - "@govoplan/files-webui": "file:../../govoplan-files/webui", - "@govoplan/mail-webui": "file:../../govoplan-mail/webui" - }, "peerDependencies": { "@govoplan/core-webui": "^0.1.1", "lucide-react": "^0.555.0", @@ -25,7 +21,8 @@ "react-router-dom": "^7.1.1" }, "scripts": { - "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js" + "test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js", + "test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts deleted file mode 100644 index 2dccc16..0000000 --- a/webui/src/api/admin.ts +++ /dev/null @@ -1,519 +0,0 @@ -import type { ApiSettings } from "../types"; -import { apiFetch } from "./client"; - -export type PermissionItem = { - scope: string; - label: string; - description: string; - category: string; - level: "tenant" | "system"; - system_template_id?: string | null; - system_required?: boolean; -}; - -export type AdminOverview = { - active_tenant_id: string; - active_tenant_name: string; - tenant_count?: number | null; - system_account_count?: number | null; - system_group_template_count?: number | null; - system_role_template_count?: number | null; - user_count: number; - active_user_count: number; - group_count: number; - role_count: number; - active_api_key_count: number; - capabilities: string[]; -}; - -export type TenantAdminItem = { - id: string; - slug: string; - name: string; - description?: string | null; - default_locale: string; - settings: Record; - allow_custom_groups?: boolean | null; - allow_custom_roles?: boolean | null; - allow_api_keys?: boolean | null; - effective_governance: Record; - is_active: boolean; - counts: Record; - created_at: string; - updated_at: string; -}; - -export type TenantOwnerCandidate = { - account_id: string; - email: string; - display_name?: string | null; -}; - -export type RoleSummary = { - id: string; - slug: string; - name: string; - description?: string | null; - permissions: string[]; - effective_permission_count: number; - is_builtin: boolean; - is_assignable: boolean; - user_assignments: number; - group_assignments: number; - level: "tenant" | "system"; - system_template_id?: string | null; - system_required?: boolean; -}; - -export type GroupSummary = { - id: string; - slug: string; - name: string; - description?: string | null; - is_active: boolean; - member_count: number; - member_ids: string[]; - roles: RoleSummary[]; - created_at: string; - updated_at: string; - system_template_id?: string | null; - system_required?: boolean; -}; - -export type UserAdminItem = { - id: string; - account_id: string; - tenant_id: string; - email: string; - display_name?: string | null; - is_active: boolean; - account_is_active: boolean; - password_reset_required: boolean; - last_login_at?: string | null; - groups: GroupSummary[]; - roles: RoleSummary[]; - effective_scopes: string[]; - is_owner: boolean; - is_last_active_owner: boolean; - created_at: string; - updated_at: string; -}; - -export type SystemAccountItem = { - account_id: string; - email: string; - display_name?: string | null; - is_active: boolean; - memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>; - roles: RoleSummary[]; - last_login_at?: string | null; -}; - - -export type SystemMembershipDraft = { - tenant_id: string; - is_active: boolean; - role_ids: string[]; - group_ids: string[]; - is_owner?: boolean; - is_last_active_owner?: boolean; -}; - -export type PrivacyRetentionPolicyFieldKey = - | "store_raw_campaign_json" - | "raw_campaign_json_retention_days" - | "generated_eml_retention_days" - | "stored_report_detail_retention_days" - | "mock_mailbox_retention_days" - | "audit_detail_retention_days" - | "audit_detail_level"; - -export type PrivacyRetentionLimitPermissions = Record; -export type PrivacyRetentionLimitPermissionPatch = Partial; - -export type PrivacyRetentionPolicy = { - store_raw_campaign_json: boolean; - raw_campaign_json_retention_days?: number | null; - generated_eml_retention_days?: number | null; - stored_report_detail_retention_days?: number | null; - mock_mailbox_retention_days?: number | null; - audit_detail_retention_days?: number | null; - audit_detail_level: "full" | "redacted" | "minimal"; - allow_lower_level_limits: PrivacyRetentionLimitPermissions; -}; - -export type PrivacyRetentionPolicyPatch = Partial> & { - allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch; -}; -export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign"; - -export type PrivacyRetentionPolicyScopeResponse = { - scope_type: PrivacyRetentionPolicyScope; - scope_id?: string | null; - policy: PrivacyRetentionPolicyPatch; - effective_policy: PrivacyRetentionPolicy; - parent_policy?: PrivacyRetentionPolicy | null; -}; - -export type SystemSettingsItem = { - default_locale: string; - allow_tenant_custom_groups: boolean; - allow_tenant_custom_roles: boolean; - allow_tenant_api_keys: boolean; - privacy_retention_policy: PrivacyRetentionPolicy; - settings: Record; -}; - -export type RetentionRunResponse = { - result: { - dry_run: boolean; - policy: PrivacyRetentionPolicy; - cutoffs: Record; - effective_policy_scope?: string; - counts: Record>; - }; -}; - -export type GovernanceAssignment = { - tenant_id: string; - mode: "available" | "required"; -}; - -export type GovernanceTemplateItem = { - id: string; - kind: "group" | "role"; - slug: string; - name: string; - description?: string | null; - permissions: string[]; - effective_permission_count: number; - is_active: boolean; - assignments: GovernanceAssignment[]; - created_at: string; - updated_at: string; -}; - -export type ApiKeyAdminItem = { - id: string; - user_id: string; - user_email: string; - name: string; - prefix: string; - scopes: string[]; - expires_at?: string | null; - last_used_at?: string | null; - revoked_at?: string | null; - created_at: string; -}; - -export type AuditAdminItem = { - id: string; - scope: "tenant" | "system"; - tenant_id?: string | null; - actor_email?: string | null; - action: string; - object_type?: string | null; - object_id?: string | null; - details: Record; - created_at: string; -}; - - - -export function fetchAdminOverview(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/overview"); -} - -export async function fetchPermissionCatalog(settings: ApiSettings): Promise { - const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions"); - return response.permissions; -} - -export async function fetchTenants(settings: ApiSettings): Promise { - const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); - return response.tenants; -} - -export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise { - const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates"); - return response.accounts; -} - -export function createTenant(settings: ApiSettings, payload: { - slug: string; - name: string; - owner_account_id?: string | null; - description?: string | null; - default_locale?: string; - settings?: Record; - allow_custom_groups?: boolean | null; - allow_custom_roles?: boolean | null; - allow_api_keys?: boolean | null; -}): Promise { - return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) }); -} - -export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{ - name: string; - description: string | null; - default_locale: string; - settings: Record; - allow_custom_groups?: boolean | null; - allow_custom_roles?: boolean | null; - allow_api_keys?: boolean | null; - effective_governance: Record; - is_active: boolean; -}>): Promise { - return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export async function fetchUsers(settings: ApiSettings): Promise { - const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users"); - return response.users; -} - -export function createUser(settings: ApiSettings, payload: { - email: string; - display_name?: string | null; - password?: string | null; - password_reset_required?: boolean; - is_active?: boolean; - group_ids?: string[]; - role_ids?: string[]; -}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> { - return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }); -} - -export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{ - display_name: string | null; - is_active: boolean; - group_ids: string[]; - role_ids: string[]; -}>): Promise { - return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export async function fetchGroups(settings: ApiSettings): Promise { - const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups"); - return response.groups; -} - -export function createGroup(settings: ApiSettings, payload: { - slug: string; - name: string; - description?: string | null; - is_active?: boolean; - member_ids?: string[]; - role_ids?: string[]; -}): Promise { - return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) }); -} - -export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{ - name: string; - description: string | null; - is_active: boolean; - member_ids: string[]; - role_ids: string[]; -}>): Promise { - return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export async function fetchRoles(settings: ApiSettings): Promise { - const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles"); - return response.roles; -} - -export function createRole(settings: ApiSettings, payload: { - slug: string; - name: string; - description?: string | null; - permissions: string[]; -}): Promise { - return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }); -} - -export function updateRole(settings: ApiSettings, roleId: string, payload: { - name: string; - description?: string | null; - permissions: string[]; - is_assignable: boolean; -}): Promise { - return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export function deleteRole(settings: ApiSettings, roleId: string): Promise { - return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" }); -} - -export async function fetchSystemRoles(settings: ApiSettings): Promise { - const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); - return response.roles; -} - -export function createSystemRole(settings: ApiSettings, payload: { - slug: string; - name: string; - description?: string | null; - permissions: string[]; -}): Promise { - return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) }); -} - -export function updateSystemRole(settings: ApiSettings, roleId: string, payload: { - name: string; - description?: string | null; - permissions: string[]; - is_assignable: boolean; -}): Promise { - return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise { - return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" }); -} - -export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> { - return apiFetch(settings, "/api/v1/admin/system/accounts"); -} - -export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: { - display_name?: string | null; - is_active?: boolean; - role_ids?: string[]; -}): Promise { - return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, { - method: "PATCH", - body: JSON.stringify(payload) - }); -} - -export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise { - return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, { - method: "PUT", - body: JSON.stringify({ role_ids: roleIds }) - }); -} - -export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise { - const params = new URLSearchParams(); - if (includeRevoked) params.set("include_revoked", "true"); - const suffix = params.toString() ? `?${params.toString()}` : ""; - const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`); - return response.api_keys; -} - -export function createApiKey(settings: ApiSettings, payload: { - name: string; - user_id?: string | null; - scopes: string[]; - expires_at?: string | null; -}): Promise { - return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) }); -} - -export function revokeApiKey(settings: ApiSettings, keyId: string): Promise { - return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" }); -} - -export type AuditQueryOptions = { - tenantId?: string | null; - allTenants?: boolean; - scope?: "tenant" | "system"; - limit?: number; - offset?: number; - page?: number; - pageSize?: number; - sortBy?: "time" | "actor" | "action" | "object" | "tenant"; - sortDirection?: "asc" | "desc"; - filters?: Partial>; -}; - -export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> { - const params = new URLSearchParams(); - if (options.tenantId) params.set("tenant_id", options.tenantId); - if (options.allTenants) params.set("all_tenants", "true"); - if (options.scope) params.set("scope", options.scope); - if (options.limit) params.set("limit", String(options.limit)); - if (options.offset) params.set("offset", String(options.offset)); - if (options.page) params.set("page", String(options.page)); - if (options.pageSize) params.set("page_size", String(options.pageSize)); - if (options.sortBy) params.set("sort_by", options.sortBy); - if (options.sortDirection) params.set("sort_direction", options.sortDirection); - for (const [column, value] of Object.entries(options.filters ?? {})) { - if (value?.trim()) params.set(`filter_${column}`, value); - } - const suffix = params.toString() ? `?${params.toString()}` : ""; - return apiFetch(settings, `/api/v1/admin/audit${suffix}`); -} - - -export function createSystemAccount(settings: ApiSettings, payload: { - email: string; - display_name?: string | null; - password?: string | null; - password_reset_required?: boolean; - is_active?: boolean; - role_ids?: string[]; - memberships?: SystemMembershipDraft[]; -}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> { - return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) }); -} - -export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise { - return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, { - method: "PUT", body: JSON.stringify({ memberships }) - }); -} - -export function fetchSystemSettings(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/system/settings"); -} - -export type SystemSettingsUpdatePayload = { - default_locale: string; - allow_tenant_custom_groups: boolean; - allow_tenant_custom_roles: boolean; - allow_tenant_api_keys: boolean; - privacy_retention_policy?: PrivacyRetentionPolicy | null; -}; - -export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise { - return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); -} - -export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise { - const params = new URLSearchParams(); - if (scopeId) params.set("scope_id", scopeId); - const suffix = params.toString() ? `?${params.toString()}` : ""; - return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`); -} - -export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise { - const params = new URLSearchParams(); - if (scopeId) params.set("scope_id", scopeId); - const suffix = params.toString() ? `?${params.toString()}` : ""; - return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) }); -} - -export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise { - return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) }); -} - -export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise { - const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; - const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); - return response.templates; -} - -export function createGovernanceTemplate(settings: ApiSettings, payload: Omit): Promise { - return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) }); -} - -export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit): Promise { - return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise { - return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" }); -} diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 6b7f3c7..5200349 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -77,6 +77,21 @@ export type CampaignVersionDetail = CampaignVersionListItem & { campaign_json?: Record; }; +export type CampaignWorkspaceResponse = { + campaign: CampaignListItem | null; + versions: CampaignVersionListItem[]; + current_version: CampaignVersionDetail | null; + summary: CampaignSummary | null; + selected_version_id?: string | null; +}; + +export type CampaignWorkspaceQuery = { + versionId?: string | null; + includeCurrentVersion?: boolean; + includeSummary?: boolean; + includeVersions?: boolean; +}; + export type CampaignVersionUpdatePayload = { campaign_json?: Record | null; current_flow?: string | null; @@ -157,6 +172,12 @@ export type CampaignSendNowPayload = { enqueue_imap_task?: boolean; }; +export type CampaignAppendSentPayload = { + dry_run?: boolean; + enqueue_celery?: boolean; + run_inline?: boolean; +}; + export type CampaignAttachmentPreviewFile = { id: string; @@ -316,6 +337,24 @@ export async function getCampaignSchema(settings: ApiSettings): Promise return apiFetch(settings, "/api/v1/schemas/campaign"); } +export async function getCampaignSchemaUi(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/schemas/campaign/ui"); +} + +export async function getCampaignWorkspace( + settings: ApiSettings, + campaignId: string, + options: CampaignWorkspaceQuery = {} +): Promise { + const params = new URLSearchParams(); + if (options.versionId) params.set("version_id", options.versionId); + if (options.includeCurrentVersion !== undefined) params.set("include_current_version", String(options.includeCurrentVersion)); + if (options.includeSummary !== undefined) params.set("include_summary", String(options.includeSummary)); + if (options.includeVersions !== undefined) params.set("include_versions", String(options.includeVersions)); + const suffix = params.size > 0 ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/workspace${suffix}`); +} + export async function listCampaignVersions( settings: ApiSettings, campaignId: string @@ -642,11 +681,15 @@ export async function cancelCampaign(settings: ApiSettings, campaignId: string): export async function appendSent( settings: ApiSettings, campaignId: string, - dryRun = false + payload: CampaignAppendSentPayload = {} ): Promise> { return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, { method: "POST", - body: JSON.stringify({ dry_run: dryRun }) + body: JSON.stringify({ + dry_run: payload.dry_run ?? false, + enqueue_celery: payload.enqueue_celery ?? true, + run_inline: payload.run_inline ?? false + }) }); } diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts index a724844..29481ed 100644 --- a/webui/src/api/files.ts +++ b/webui/src/api/files.ts @@ -1 +1,91 @@ -export * from "@govoplan/files-webui"; +import type { ApiSettings } from "../types"; +import { apiFetch } from "./client"; + +export type FileSpace = { + id: string; + label: string; + owner_type: "user" | "group"; + owner_id: string; + description?: string | null; +}; + +export type FileShare = { + id: string; + target_type: string; + target_id: string; + permission: string; + created_at: string; + revoked_at?: string | null; +}; + +export type ManagedFile = { + id: string; + tenant_id: string; + owner_type: "user" | "group"; + owner_id: string; + display_path: string; + filename: string; + description?: string | null; + size_bytes: number; + content_type?: string | null; + checksum_sha256: string; + version_id: string; + created_at: string; + updated_at: string; + deleted_at?: string | null; + audit_relevant: boolean; + metadata?: Record | null; + shares?: FileShare[]; +}; + +export type FileListResponse = { files: ManagedFile[] }; +export type FileSpacesResponse = { spaces: FileSpace[] }; +export type FileFolder = { + id: string; + tenant_id: string; + owner_type: "user" | "group"; + owner_id: string; + path: string; + created_at: string; + updated_at: string; + deleted_at?: string | null; +}; +export type FileFoldersResponse = { folders: FileFolder[] }; +export type PatternResolveResponse = { + patterns: { pattern: string; matches: ManagedFile[] }[]; + unmatched: ManagedFile[]; +}; + +export function listFileSpaces(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/files/spaces"); +} + +export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise { + const search = new URLSearchParams(); + search.set("owner_type", params.owner_type); + search.set("owner_id", params.owner_id); + return apiFetch(settings, `/api/v1/files/folders?${search.toString()}`); +} + +export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value) search.set(key, value); + } + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/files${suffix}`); +} + +export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise { + return apiFetch(settings, `/api/v1/files/${fileId}/shares`, { + method: "POST", + body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" }) + }); +} + +export function resolveFilePatterns( + settings: ApiSettings, + payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean } +): Promise { + return apiFetch(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) }); +} diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 1b00321..9b57023 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -1 +1,222 @@ -export * from "@govoplan/mail-webui"; +import type { ApiSettings } from "../types"; +import { apiFetch } from "./client"; + +export type MailSecurity = "plain" | "tls" | "starttls"; +export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign"; + +export type MailSmtpTestPayload = { + host?: string | null; + port?: number | null; + username?: string | null; + password?: string | null; + security?: MailSecurity; + timeout_seconds?: number; +}; + +export type MailImapTestPayload = MailSmtpTestPayload & { + sent_folder?: string | null; +}; + +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; +}; + +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 ?? []; +} + +export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise { + return apiFetch(settings, "/api/v1/mail/profiles", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function getMailProfilePolicy( + settings: ApiSettings, + scopeType: MailProfileScope, + 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}`); +} + +export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" }); +} + +export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" }); +} + +export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise { + return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" }); +} + +export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise { + return apiFetch(settings, "/api/v1/mail/test-smtp", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise { + return apiFetch(settings, "/api/v1/mail/test-imap", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise { + return apiFetch(settings, "/api/v1/mail/list-imap-folders", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +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)}`); +} diff --git a/webui/src/components/Button.tsx b/webui/src/components/Button.tsx deleted file mode 100644 index 710440c..0000000 --- a/webui/src/components/Button.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Button } from "@govoplan/core-webui"; -export default Button; diff --git a/webui/src/components/Card.tsx b/webui/src/components/Card.tsx deleted file mode 100644 index e1eeb17..0000000 --- a/webui/src/components/Card.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Card } from "@govoplan/core-webui"; -export default Card; diff --git a/webui/src/components/ConfirmDialog.tsx b/webui/src/components/ConfirmDialog.tsx deleted file mode 100644 index eeb657c..0000000 --- a/webui/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { ConfirmDialog } from "@govoplan/core-webui"; -export default ConfirmDialog; diff --git a/webui/src/components/Dialog.tsx b/webui/src/components/Dialog.tsx deleted file mode 100644 index e0b9e8b..0000000 --- a/webui/src/components/Dialog.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Dialog } from "@govoplan/core-webui"; -export default Dialog; diff --git a/webui/src/components/DismissibleAlert.tsx b/webui/src/components/DismissibleAlert.tsx deleted file mode 100644 index 09ad137..0000000 --- a/webui/src/components/DismissibleAlert.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { DismissibleAlert } from "@govoplan/core-webui"; -export default DismissibleAlert; diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx deleted file mode 100644 index 59b6792..0000000 --- a/webui/src/components/FormField.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { FormField } from "@govoplan/core-webui"; -export default FormField; diff --git a/webui/src/components/LoadingFrame.tsx b/webui/src/components/LoadingFrame.tsx deleted file mode 100644 index c007f26..0000000 --- a/webui/src/components/LoadingFrame.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { LoadingFrame } from "@govoplan/core-webui"; -export default LoadingFrame; diff --git a/webui/src/components/LoadingIndicator.tsx b/webui/src/components/LoadingIndicator.tsx deleted file mode 100644 index e2e63cc..0000000 --- a/webui/src/components/LoadingIndicator.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { LoadingIndicator } from "@govoplan/core-webui"; -export default LoadingIndicator; diff --git a/webui/src/components/MetricCard.tsx b/webui/src/components/MetricCard.tsx deleted file mode 100644 index 5af7d1f..0000000 --- a/webui/src/components/MetricCard.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { MetricCard } from "@govoplan/core-webui"; -export default MetricCard; diff --git a/webui/src/components/PageTitle.tsx b/webui/src/components/PageTitle.tsx deleted file mode 100644 index 2a58990..0000000 --- a/webui/src/components/PageTitle.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { PageTitle } from "@govoplan/core-webui"; -export default PageTitle; diff --git a/webui/src/components/StatusBadge.tsx b/webui/src/components/StatusBadge.tsx deleted file mode 100644 index 13347b8..0000000 --- a/webui/src/components/StatusBadge.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { StatusBadge } from "@govoplan/core-webui"; -export default StatusBadge; diff --git a/webui/src/components/Stepper.tsx b/webui/src/components/Stepper.tsx deleted file mode 100644 index fc27f21..0000000 --- a/webui/src/components/Stepper.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { Stepper } from "@govoplan/core-webui"; -export default Stepper; diff --git a/webui/src/components/ToggleSwitch.tsx b/webui/src/components/ToggleSwitch.tsx deleted file mode 100644 index cae981b..0000000 --- a/webui/src/components/ToggleSwitch.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { ToggleSwitch } from "@govoplan/core-webui"; -export default ToggleSwitch; diff --git a/webui/src/components/email/EmailAddressInput.tsx b/webui/src/components/email/EmailAddressInput.tsx deleted file mode 100644 index 40f5009..0000000 --- a/webui/src/components/email/EmailAddressInput.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { EmailAddressInput } from "@govoplan/core-webui"; -export default EmailAddressInput; diff --git a/webui/src/components/help/FieldLabel.tsx b/webui/src/components/help/FieldLabel.tsx deleted file mode 100644 index b205494..0000000 --- a/webui/src/components/help/FieldLabel.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { FieldLabel } from "@govoplan/core-webui"; -export default FieldLabel; diff --git a/webui/src/components/help/InlineHelp.tsx b/webui/src/components/help/InlineHelp.tsx deleted file mode 100644 index d2cb6bf..0000000 --- a/webui/src/components/help/InlineHelp.tsx +++ /dev/null @@ -1,2 +0,0 @@ -import { InlineHelp } from "@govoplan/core-webui"; -export default InlineHelp; diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx index c237ae7..719978e 100644 --- a/webui/src/features/addressbook/AddressBookPage.tsx +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -1,7 +1,7 @@ -import Button from "../../components/Button"; -import Card from "../../components/Card"; -import PageTitle from "../../components/PageTitle"; -import StatusBadge from "../../components/StatusBadge"; +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 = [ diff --git a/webui/src/features/campaigns/AttachmentsDataPage.tsx b/webui/src/features/campaigns/AttachmentsDataPage.tsx index 4bae1c1..7a5dc11 100644 --- a/webui/src/features/campaigns/AttachmentsDataPage.tsx +++ b/webui/src/features/campaigns/AttachmentsDataPage.tsx @@ -1,16 +1,17 @@ import { useEffect, useMemo, useState } from "react"; import { Pencil } from "lucide-react"; +import { usePlatformModuleInstalled } from "@govoplan/core-webui"; import type { ApiSettings } from "../../types"; import { listFileSpaces, type FileSpace } from "../../api/files"; -import Button from "../../components/Button"; -import Card from "../../components/Card"; -import PageTitle from "../../components/PageTitle"; -import LoadingFrame from "../../components/LoadingFrame"; +import { 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 ToggleSwitch from "../../components/ToggleSwitch"; -import DismissibleAlert from "../../components/DismissibleAlert"; -import ConfirmDialog from "../../components/ConfirmDialog"; +import { ToggleSwitch } from "@govoplan/core-webui"; +import { DismissibleAlert } from "@govoplan/core-webui"; +import { ConfirmDialog } from "@govoplan/core-webui"; import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; @@ -20,7 +21,7 @@ import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay"; import ManagedFileChooser from "./components/ManagedFileChooser"; import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog"; import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments"; -import { insertAfter, moveArrayItem } from "../../utils/arrayOrder"; +import { insertAfter, moveArrayItem } from "@govoplan/core-webui"; import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions"; import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; @@ -28,6 +29,7 @@ type PathChooserState = { index: number }; type IndividualDisableState = { index: number; usageCount: number }; export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { + const filesModuleInstalled = usePlatformModuleInstalled("files"); const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const [pathChooser, setPathChooser] = useState(null); const [fileSpaces, setFileSpaces] = useState([]); @@ -61,12 +63,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]); useEffect(() => { + if (!filesModuleInstalled) { + setFileSpaces([]); + return; + } let cancelled = false; void listFileSpaces(settings) .then((response) => { if (!cancelled) setFileSpaces(response.spaces); }) .catch(() => { if (!cancelled) setFileSpaces([]); }); return () => { cancelled = true; }; - }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + }, [filesModuleInstalled, settings.apiBaseUrl, settings.apiKey, settings.accessToken]); function patchBasePaths(paths: AttachmentBasePath[]) { if (locked) return; @@ -220,7 +226,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
- + {filesModuleInstalled && }
@@ -233,15 +239,17 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings <> - basePath.id} - emptyText="No attachment sources configured." - emptyAction={ addBasePath(-1)} disabled={locked} label="Add first attachment source" />} - className="attachment-sources-table-wrap attachment-sources-table" - /> +
+ basePath.id} + emptyText="No attachment sources configured." + emptyAction={ addBasePath(-1)} disabled={locked} label="Add first attachment source" />} + className="attachment-sources-table-wrap attachment-sources-table" + /> +
@@ -253,26 +261,28 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings onChange={setZipEnabled} /> - archive.id} - emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."} - emptyAction={zipConfig.enabled ? addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined} - className="attachment-zip-table-wrap" - /> +
+ archive.id} + emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."} + emptyAction={zipConfig.enabled ? addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined} + className="attachment-zip-table-wrap" + /> +
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && ( No password field exists. Add one under Fields with field type Password. )} @@ -282,7 +292,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings

Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.

- + patch(["attachments", "global"], rules)} /> - +
Base paths
{basePaths.length}
Global attachments
direct: {globalSummary.direct} / rules: {globalSummary.rules}
Per-recipient patterns
{individualRulesCount}
-
Upload support
Connected through Files
+
Upload support
{filesModuleInstalled ? "Connected through Files" : "Manual paths"}
-

Files are now managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build.

+

{filesModuleInstalled ? "Files are managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build." : "The Files module is not installed. Use manual paths and patterns; managed file browsing and sharing are unavailable."}

-
- -
@@ -340,7 +348,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)} /> - {pathChooser && ( + {pathChooser && filesModuleInstalled && ( ) => void; setIndividualEligibility: (index: number, checked: boolean) => void; addBasePath: (afterIndex?: number) => void; @@ -554,7 +563,7 @@ type AttachmentSourceColumnContext = { setPathChooser: (state: PathChooserState | null) => void; }; -function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn[] { +function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn[] { return [ { id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name }, { @@ -568,20 +577,23 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
!locked && setPathChooser({ index })} + onChange={(event) => { + if (!filesModuleInstalled) patchBasePath(index, { path: event.target.value, source: "" }); + }} + onClick={() => filesModuleInstalled && !locked && setPathChooser({ index })} onKeyDown={(event) => { - if (!locked && (event.key === "Enter" || event.key === " ")) { + if (filesModuleInstalled && !locked && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); setPathChooser({ index }); } }} /> - + {filesModuleInstalled && }
), value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces) diff --git a/webui/src/features/campaigns/CampaignAuditPage.tsx b/webui/src/features/campaigns/CampaignAuditPage.tsx index e18cf33..014a755 100644 --- a/webui/src/features/campaigns/CampaignAuditPage.tsx +++ b/webui/src/features/campaigns/CampaignAuditPage.tsx @@ -1,10 +1,10 @@ import type { ApiSettings } from "../../types"; -import Button from "../../components/Button"; -import Card from "../../components/Card"; -import DismissibleAlert from "../../components/DismissibleAlert"; -import PageTitle from "../../components/PageTitle"; +import { Button } from "@govoplan/core-webui"; +import { Card } from "@govoplan/core-webui"; +import { DismissibleAlert } from "@govoplan/core-webui"; +import { PageTitle } from "@govoplan/core-webui"; import VersionLine from "./components/VersionLine"; -import LoadingFrame from "../../components/LoadingFrame"; +import { LoadingFrame } from "@govoplan/core-webui"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { diff --git a/webui/src/features/campaigns/CampaignFieldsPage.tsx b/webui/src/features/campaigns/CampaignFieldsPage.tsx index b0ba6f4..6809426 100644 --- a/webui/src/features/campaigns/CampaignFieldsPage.tsx +++ b/webui/src/features/campaigns/CampaignFieldsPage.tsx @@ -1,22 +1,21 @@ import { useMemo, useRef } from "react"; import type { ApiSettings } from "../../types"; -import Button from "../../components/Button"; -import PageTitle from "../../components/PageTitle"; -import LoadingFrame from "../../components/LoadingFrame"; +import { Button } from "@govoplan/core-webui"; +import { PageTitle } from "@govoplan/core-webui"; +import { Card } from "@govoplan/core-webui"; +import { LoadingFrame } from "@govoplan/core-webui"; import LockedVersionNotice from "./components/LockedVersionNotice"; import VersionLine from "./components/VersionLine"; -import ToggleSwitch from "../../components/ToggleSwitch"; +import { ToggleSwitch } from "@govoplan/core-webui"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { asRecord, isAuditLockedVersion, isRecord } from "./utils/campaignView"; import { getBool, getText, updateNested } from "./utils/draftEditor"; import FieldValueInput from "./components/FieldValueInput"; -import DismissibleAlert from "../../components/DismissibleAlert"; +import { DismissibleAlert } from "@govoplan/core-webui"; import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions"; -import { insertAfter, moveArrayItem } from "../../utils/arrayOrder"; - - +import { insertAfter, moveArrayItem } from "@govoplan/core-webui"; export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); const fieldValueKeys = useRef([]); @@ -170,21 +169,19 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings: <> -
- `field-row-${index}`} - emptyText="No campaign fields configured yet." - emptyAction={ addField(-1)} disabled={locked} label="Add first field" />} - className="field-editor-table-wrap field-editor-table" - /> -
- -
- -
+ +
+ `field-row-${index}`} + emptyText="No campaign fields configured yet." + emptyAction={ addField(-1)} disabled={locked} label="Add first field" />} + className="field-editor-table-wrap field-editor-table" + /> +
+
diff --git a/webui/src/features/campaigns/CampaignJsonView.tsx b/webui/src/features/campaigns/CampaignJsonView.tsx index a98bf72..58f5beb 100644 --- a/webui/src/features/campaigns/CampaignJsonView.tsx +++ b/webui/src/features/campaigns/CampaignJsonView.tsx @@ -1,10 +1,10 @@ import type { ApiSettings } from "../../types"; -import Card from "../../components/Card"; -import Button from "../../components/Button"; -import DismissibleAlert from "../../components/DismissibleAlert"; -import PageTitle from "../../components/PageTitle"; +import { Card } from "@govoplan/core-webui"; +import { Button } from "@govoplan/core-webui"; +import { DismissibleAlert } from "@govoplan/core-webui"; +import { PageTitle } from "@govoplan/core-webui"; import VersionLine from "./components/VersionLine"; -import LoadingFrame from "../../components/LoadingFrame"; +import { LoadingFrame } from "@govoplan/core-webui"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { asRecord, getCampaignJson } from "./utils/campaignView"; import { downloadJson, safeFileStem } from "./utils/draftEditor"; diff --git a/webui/src/features/campaigns/CampaignListPage.tsx b/webui/src/features/campaigns/CampaignListPage.tsx index 0c086d5..fec7327 100644 --- a/webui/src/features/campaigns/CampaignListPage.tsx +++ b/webui/src/features/campaigns/CampaignListPage.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from "react"; +import { ExternalLink } from "lucide-react"; import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate } from "@govoplan/core-webui"; import { Link, useNavigate } from "react-router-dom"; import type { ApiSettings } from "../../types"; -import Card from "../../components/Card"; -import Button from "../../components/Button"; -import StatusBadge from "../../components/StatusBadge"; -import PageTitle from "../../components/PageTitle"; -import LoadingFrame from "../../components/LoadingFrame"; -import DismissibleAlert from "../../components/DismissibleAlert"; +import { Card } from "@govoplan/core-webui"; +import { Button } from "@govoplan/core-webui"; +import { StatusBadge } from "@govoplan/core-webui"; +import { PageTitle } from "@govoplan/core-webui"; +import { LoadingFrame } from "@govoplan/core-webui"; +import { DismissibleAlert } from "@govoplan/core-webui"; import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; import { createNewCampaign, listCampaigns } from "../../api/campaigns"; import type { CampaignListItem } from "../../types"; @@ -112,9 +113,19 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings } { id: "actions", header: "Actions", - width: 110, + width: 70, sticky: "end", - render: (campaign) => + align: "right", + render: (campaign) => ( + +