Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

6
.gitignore vendored
View File

@@ -326,4 +326,8 @@ cython_debug/
# Built Visual Studio Code Extensions
*.vsix
**/runtime/
**/runtime/
# GovOPlaN WebUI test output
webui/.policy-test-build/
webui/.template-preview-test-build/

38
AGENTS.md Normal file
View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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",

View File

@@ -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),
)

View File

@@ -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())

View File

@@ -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

View File

@@ -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,

View File

@@ -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 []},
}

View File

@@ -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

View File

@@ -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()}

View File

@@ -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)

View File

@@ -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]

View File

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

View File

@@ -0,0 +1,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))

View File

@@ -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,

View File

@@ -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)

View File

@@ -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:

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -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,

View File

@@ -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]] = [
{

View File

@@ -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))

View File

@@ -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

View File

@@ -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."
}
}

View File

@@ -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

View File

@@ -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 {},

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

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

View File

@@ -77,6 +77,21 @@ export type CampaignVersionDetail = CampaignVersionListItem & {
campaign_json?: Record<string, unknown>;
};
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<string, unknown> | 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<unknown>
return apiFetch(settings, "/api/v1/schemas/campaign");
}
export async function getCampaignSchemaUi(settings: ApiSettings): Promise<unknown> {
return apiFetch(settings, "/api/v1/schemas/campaign/ui");
}
export async function getCampaignWorkspace(
settings: ApiSettings,
campaignId: string,
options: CampaignWorkspaceQuery = {}
): Promise<CampaignWorkspaceResponse> {
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<CampaignWorkspaceResponse>(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<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(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
})
});
}

View File

@@ -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<string, unknown> | 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<FileSpacesResponse> {
return apiFetch<FileSpacesResponse>(settings, "/api/v1/files/spaces");
}
export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise<FileFoldersResponse> {
const search = new URLSearchParams();
search.set("owner_type", params.owner_type);
search.set("owner_id", params.owner_id);
return apiFetch<FileFoldersResponse>(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<FileListResponse> {
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<FileListResponse>(settings, `/api/v1/files${suffix}`);
}
export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
return apiFetch<FileShare>(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<PatternResolveResponse> {
return apiFetch<PatternResolveResponse>(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) });
}

View File

@@ -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<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
details?: Record<string, unknown>;
};
export type MailServerProfile = {
id: string;
tenant_id?: string | null;
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
updated_at: string;
};
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const;
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: boolean | null;
};
export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null;
allow_user_profiles?: boolean | null;
allow_group_profiles?: boolean | null;
allow_campaign_profiles?: boolean | null;
smtp_credentials?: MailCredentialPolicy | null;
imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null;
};
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
label: string;
applied_fields?: string[];
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
parent_policy?: MailProfilePolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
credentials?: MailServerProfileCredentialsPayload | null;
};
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams();
if (includeInactive) params.set("include_inactive", "true");
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
return response.profiles ?? [];
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(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<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
}
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
method: "POST",
body: JSON.stringify(payload)
});
}
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = [

View File

@@ -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<PathChooserState | null>(null);
const [fileSpaces, setFileSpaces] = useState<FileSpace[]>([]);
@@ -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
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>
{filesModuleInstalled && <Button onClick={() => { window.location.href = "/files"; }}>Manage files</Button>}
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
@@ -233,15 +239,17 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Attachment sources">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
className="attachment-sources-table-wrap attachment-sources-table"
/>
<div className="admin-table-surface attachment-sources-table-surface">
<DataGrid
id={`campaign-${campaignId}-attachment-sources`}
rows={basePaths}
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
getRowKey={(basePath) => basePath.id}
emptyText="No attachment sources configured."
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="Add first attachment source" />}
className="attachment-sources-table-wrap attachment-sources-table"
/>
</div>
</Card>
<Card title="ZIP attachments" collapsible>
@@ -253,26 +261,28 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
onChange={setZipEnabled}
/>
</div>
<DataGrid
id={`campaign-${campaignId}-zip-archives`}
rows={zipConfig.archives}
columns={zipArchiveColumns({
disabled: locked || !zipConfig.enabled,
archives: zipConfig.archives,
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
onEditName: setZipNameEditorIndex,
passwordFields,
patchArchive: patchZipArchive,
setStandard: setStandardZipArchive,
addArchive: addZipArchive,
moveArchive: moveZipArchive,
removeArchive: removeZipArchive
})}
getRowKey={(archive) => archive.id}
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
className="attachment-zip-table-wrap"
/>
<div className="admin-table-surface attachment-zip-table-surface">
<DataGrid
id={`campaign-${campaignId}-zip-archives`}
rows={zipConfig.archives}
columns={zipArchiveColumns({
disabled: locked || !zipConfig.enabled,
archives: zipConfig.archives,
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
onEditName: setZipNameEditorIndex,
passwordFields,
patchArchive: patchZipArchive,
setStandard: setStandardZipArchive,
addArchive: addZipArchive,
moveArchive: moveZipArchive,
removeArchive: removeZipArchive
})}
getRowKey={(archive) => archive.id}
emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."}
emptyAction={zipConfig.enabled ? <DataGridEmptyAction onAdd={() => addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined}
className="attachment-zip-table-wrap"
/>
</div>
{zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && (
<DismissibleAlert tone="warning">No password field exists. Add one under Fields with field type <strong>Password</strong>.</DismissibleAlert>
)}
@@ -282,7 +292,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<p className="muted small-note">Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.</p>
</Card>
<Card title="Global Attachments">
<Card title="Global Attachments" collapsible>
<AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
@@ -292,23 +302,21 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
settings={settings}
campaignId={campaignId}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
</Card>
<Card title="Statistics">
<Card title="Statistics" collapsible>
<dl className="detail-list">
<div><dt>Base paths</dt><dd>{basePaths.length}</dd></div>
<div><dt>Global attachments</dt><dd>direct: {globalSummary.direct} / rules: {globalSummary.rules}</dd></div>
<div><dt>Per-recipient patterns</dt><dd>{individualRulesCount}</dd></div>
<div><dt>Upload support</dt><dd>Connected through Files</dd></div>
<div><dt>Upload support</dt><dd>{filesModuleInstalled ? "Connected through Files" : "Manual paths"}</dd></div>
</dl>
<p className="muted small-note">Files are now managed in the top-level Files module. Upload there, share files with campaigns, then use these rules to resolve concrete attachments during review/build.</p>
<p className="muted small-note">{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."}</p>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>Save</Button>
</div>
</>
</LoadingFrame>
@@ -340,7 +348,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)}
/>
{pathChooser && (
{pathChooser && filesModuleInstalled && (
<ManagedFileChooser
open
settings={settings}
@@ -546,6 +554,7 @@ type AttachmentSourceColumnContext = {
locked: boolean;
basePaths: AttachmentBasePath[];
fileSpaces: FileSpace[];
filesModuleInstalled: boolean;
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => 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<AttachmentBasePath>[] {
function attachmentSourceColumns({ locked, basePaths, fileSpaces, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
return [
{ id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="Campaign files" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
{
@@ -568,20 +577,23 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath,
<div className="field-with-action split-field-action">
<input
className="chooser-display-input"
value={formatAttachmentSourcePath(basePath, fileSpaces)}
value={filesModuleInstalled ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
disabled={locked}
readOnly
tabIndex={-1}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
placeholder="attachments"
onClick={() => !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 });
}
}}
/>
<Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>
{filesModuleInstalled && <Button onClick={() => setPathChooser({ index })} disabled={locked}>Choose folder</Button>}
</div>
),
value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces)

View File

@@ -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 }) {

View File

@@ -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<string[]>([]);
@@ -170,21 +169,19 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<div className="admin-table-surface campaign-fields-table-surface">
<DataGrid
id={`campaign-${campaignId}-fields`}
rows={fields}
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
getRowKey={(_field, index) => `field-row-${index}`}
emptyText="No campaign fields configured yet."
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
className="field-editor-table-wrap field-editor-table"
/>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={saveFields} disabled={!canSave}>Save</Button>
</div>
<Card title="Campaign fields">
<div className="admin-table-surface campaign-fields-table-surface">
<DataGrid
id={`campaign-${campaignId}-fields`}
rows={fields}
columns={fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField })}
getRowKey={(_field, index) => `field-row-${index}`}
emptyText="No campaign fields configured yet."
emptyAction={<DataGridEmptyAction onAdd={() => addField(-1)} disabled={locked} label="Add first field" />}
className="field-editor-table-wrap field-editor-table"
/>
</div>
</Card>
</>
</LoadingFrame>
</div>

View File

@@ -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";

View File

@@ -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) => <Link to={`/campaigns/${campaign.id}`}><Button variant="primary">Open</Button></Link>
align: "right",
render: (campaign) => (
<Link
to={`/campaigns/${campaign.id}`}
className="btn btn-primary admin-icon-button"
aria-label={`Open ${campaign.name || campaign.external_id || campaign.id}`}
title={`Open ${campaign.name || campaign.external_id || campaign.id}`}
>
<ExternalLink aria-hidden="true" />
</Link>
)
}
];
@@ -135,7 +146,7 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
</div>
</div>
<Card>
<Card title="Campaigns">
<LoadingFrame loading={loading} label="Loading campaigns…">
{campaigns.length === 0 ? (
<div className="empty-state">
@@ -146,15 +157,17 @@ export default function CampaignListPage({ settings }: { settings: ApiSettings }
</Button>
</div>
) : (
<DataGrid
id="campaigns"
rows={campaigns}
columns={columns}
getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }}
className="campaign-table-wrap"
emptyText="No campaigns found."
/>
<div className="admin-table-surface campaigns-table-surface">
<DataGrid
id="campaigns"
rows={campaigns}
columns={columns}
getRowKey={(campaign) => campaign.id}
initialSort={{ columnId: "updated", direction: "desc" }}
className="campaign-table-wrap"
emptyText="No campaigns found."
/>
</div>
)}
</LoadingFrame>
</Card>

View File

@@ -1,33 +1,39 @@
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, LockKeyhole } from "lucide-react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import ConfirmDialog from "../../components/ConfirmDialog";
import FormField from "../../components/FormField";
import LoadingFrame from "../../components/LoadingFrame";
import MetricCard from "../../components/MetricCard";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import DismissibleAlert from "../../components/DismissibleAlert";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import {
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
unlockCampaignVersionUserLock,
updateCampaignMetadata,
type CampaignVersionDetail,
type CampaignVersionListItem,
} from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import {
asArray,
asRecord,
canUnlockValidationVersion,
formatDateTime,
getCampaignJson,
isFinalLockedVersion,
isPermanentUserLockedVersion,
isTemporaryUserLockedVersion,
isVersionReadyForDelivery,
summaryValue,
} from "./utils/campaignView";
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
@@ -43,6 +49,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
const [lockBusy, setLockBusy] = useState(false);
const [message, setMessage] = useState("");
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
useEffect(() => {
if (!campaign || identityDirty) return;
@@ -125,6 +132,13 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<LoadingFrame loading={loading} label="Loading campaign overview…">
<div className="metric-grid campaign-overview-metrics current-version-metrics">
<MetricCard label="Version" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
<MetricCard label="Fields" value={versionMetrics.fieldCount} tone="info" />
<MetricCard label="Recipients" value={versionMetrics.recipientCount} tone="neutral" detail="Active inline recipients" />
<MetricCard label="Template health" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
</div>
<div className="metric-grid campaign-overview-metrics">
<MetricCard label="Queueable" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="Ready or warning" />
<MetricCard label="Needs attention" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="Review first" />
@@ -132,7 +146,23 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
<MetricCard label="Failed" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Campaign identity">
<Card title="Current version state" actions={<Link
to={`send?version=${campaign?.current_version_id}`}
className={`btn btn-primary`}
aria-label={`Open curent version`}
title={`Open curent version`}
>
Open
</Link>}>
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
</div>
</Card>
<Card title="Campaign identity" collapsible>
<div className="form-grid campaign-identity-grid">
<FormField label="Campaign ID">
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
@@ -151,25 +181,18 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
</div>
</Card>
<Card title="Version history">
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="No versions found."
className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
/>
</Card>
<Card title="Current version state">
<div className="summary-grid overview-summary-grid">
<SummaryTile label="Validation errors" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Built messages" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Jobs total" value={data.summary?.cards?.jobs_total ?? "—"} />
<Card title="Version history" collapsible>
<div className="admin-table-surface">
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="No versions found."
className="version-history-table"
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined}
/>
</div>
</Card>
</LoadingFrame>
@@ -188,6 +211,63 @@ export default function CampaignOverviewPage({ settings, campaignId }: { setting
);
}
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
type CampaignVersionMetrics = {
fieldCount: number;
recipientCount: number;
templateHealthValue: string;
templateHealthTone: TemplateHealthTone;
templateHealthDetail: string;
};
function campaignVersionMetrics(version: CampaignVersionDetail | null): CampaignVersionMetrics {
const campaignJson = getCampaignJson(version);
const fields = asArray(campaignJson.fields).map(asRecord);
const entries = asRecord(campaignJson.entries);
const inlineEntries = asArray(entries.inline).map(asRecord);
const template = asRecord(campaignJson.template);
const globalValues = asRecord(campaignJson.global_values);
const bodyMode = normalizeTemplateBodyMode(textValue(template.body_mode, "both"));
const subject = textValue(template.subject);
const textBody = bodyMode !== "html" ? textValue(template.text) : "";
const htmlBody = bodyMode !== "text" ? textValue(template.html) : "";
const subjectOk = subject.trim().length > 0;
const bodyOk = bodyMode === "html" ? htmlBody.trim().length > 0 : bodyMode === "text" ? textBody.trim().length > 0 : Boolean(textBody.trim() || htmlBody.trim());
const localFieldNames = fields.map((field) => textValue(field.name || field.id)).filter(Boolean);
const globalFieldNames = Object.keys(globalValues).filter(Boolean);
const addressFieldNames = recipientAddressTemplateFieldOptions().map((field) => field.name);
const localAvailableNames = new Set([...localFieldNames, ...addressFieldNames]);
const globalAvailableNames = new Set(globalFieldNames);
const allAvailableNames = new Set([...localAvailableNames, ...globalAvailableNames]);
const placeholders = extractTemplatePlaceholders([subject, textBody, htmlBody].join("\n"));
const undefinedPlaceholders = buildUndefinedPlaceholders(placeholders, allAvailableNames, {
local: localAvailableNames,
global: globalAvailableNames
});
const placeholdersOk = undefinedPlaceholders.length === 0;
const score = [subjectOk, bodyOk, placeholdersOk].filter(Boolean).length;
return {
fieldCount: localFieldNames.length,
recipientCount: inlineEntries.filter((entry) => entry.active !== false).length,
templateHealthValue: `${score}/3`,
templateHealthTone: score === 3 ? "good" : score === 2 ? "warning" : "danger",
templateHealthDetail: [
subjectOk ? "Subject OK" : "Subject missing",
bodyOk ? "Body OK" : "Body missing",
placeholdersOk ? "Placeholders OK" : `${undefinedPlaceholders.length} undefined`
].join(" · ")
};
}
function normalizeTemplateBodyMode(value: string): "text" | "html" | "both" {
return value === "text" || value === "html" || value === "both" ? value : "both";
}
function textValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [
{ id: "version", header: "Version", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
@@ -199,20 +279,34 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
{
id: "actions",
header: "Actions",
width: 310,
width: 260,
sticky: "end",
render: (version) => {
const isCurrent = version.id === currentVersionId;
return (
<div className="button-row compact-actions">
<Link to={`send?version=${version.id}`}><Button variant={isCurrent ? "primary" : "secondary"}>Open</Button></Link>
<Link
to={`send?version=${version.id}`}
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
aria-label={`Open version ${version.version_number}`}
title={`Open version ${version.version_number}`}
>
<ExternalLink aria-hidden="true" />
</Link>
{isCurrent && (isTemporaryUserLockedVersion(version) ? (
<>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>Unlock</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>Lock permanently</Button>
</>
) : !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ? (
<Button onClick={() => setPendingLockAction({ version, action: "temporary" })}>Lock</Button>
<Button
className="admin-icon-button"
onClick={() => setPendingLockAction({ version, action: "temporary" })}
aria-label={`Temporarily lock version ${version.version_number}`}
title="Temporarily lock version"
>
<LockKeyhole aria-hidden="true" />
</Button>
) : null)}
</div>
);

View File

@@ -11,16 +11,16 @@ import {
type CampaignJobDetailResponse,
type CampaignJobsResponse,
} from "../../api/campaigns";
import Card from "../../components/Card";
import Button from "../../components/Button";
import ConfirmDialog from "../../components/ConfirmDialog";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } 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, formatDateTime, humanize, stringifyPreview } from "./utils/campaignView";

View File

@@ -17,18 +17,20 @@ import SendWizard from "./wizard/SendWizard";
import CampaignJsonView from "./CampaignJsonView";
import CampaignReportPage from "./CampaignReportPage";
import CampaignAuditPage from "./CampaignAuditPage";
import { CampaignUnsavedChangesProvider, useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
import { useCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
overview: "",
campaign: "recipients",
"global-settings": "global-settings",
policies: "policies",
fields: "fields",
recipients: "recipients",
"recipient-data": "recipient-data",
template: "template",
files: "files",
"mail-settings": "mail-settings",
"mail-policy": "mail-policy",
review: "review",
report: "report",
audit: "audit",
@@ -36,11 +38,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
};
export default function CampaignWorkspace({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
return (
<CampaignUnsavedChangesProvider>
<CampaignWorkspaceInner settings={settings} auth={auth} />
</CampaignUnsavedChangesProvider>
);
return <CampaignWorkspaceInner settings={settings} auth={auth} />;
}
function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
@@ -91,9 +89,13 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="attachments" element={<Navigate to="../files" replace />} />
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
<Route path="mail-policies" element={<Navigate to="../mail-policy" replace />} />
<Route path="server-settings" element={<Navigate to="../mail-settings" replace />} />
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="settings" />} />
<Route path="policies" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="policy" />} />
<Route path="policy" element={<Navigate to="../policies" replace />} />
<Route path="review" element={<ReviewSendPage settings={settings} campaignId={campaignId || ""} />} />
<Route path="send" element={<Navigate to="../review" replace />} />
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
@@ -122,12 +124,14 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
if (!section || section === "wizard" || section === "create") return "overview";
if (section === "data" || section === "campaign") return "recipients";
if (section === "global-settings" || section === "settings") return "global-settings";
if (section === "policies" || section === "policy") return "policies";
if (section === "fields") return "fields";
if (section === "recipients") return "recipients";
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
if (section === "template") return "template";
if (section === "files" || section === "attachments") return "files";
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
if (section === "mail-policy" || section === "mail-policies") return "mail-policy";
if (section === "review") return "review";
if (section === "send") return "review";
if (section === "report" || section === "reports") return "report";

View File

@@ -1,16 +1,16 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import type { ApiSettings, AuthInfo } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import DismissibleAlert from "../../components/DismissibleAlert";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import CampaignAccessCard from "./components/CampaignAccessCard";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { hasScope } from "../../utils/permissions";
import { ToggleSwitch } from "@govoplan/core-webui";
import { hasScope } from "@govoplan/core-webui";
import { RetentionPolicyEditor } from "../privacy/RetentionPolicyManagement";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
@@ -20,10 +20,19 @@ import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/dr
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
type EditorState = Record<string, unknown>;
type SettingsView = "settings" | "policy";
export default function GlobalSettingsPage({ settings, auth, campaignId }: { settings: ApiSettings; auth: AuthInfo; campaignId: string }) {
type GlobalSettingsPageProps = {
settings: ApiSettings;
auth: AuthInfo;
campaignId: string;
view?: SettingsView;
};
export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [editorState, setEditorState] = useState<EditorState>({});
const isPolicyView = view === "policy";
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
@@ -34,9 +43,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
locked,
reload,
setError,
currentStep: "global-settings",
unsavedTitle: "Unsaved global settings",
unsavedMessage: "Policies have unsaved changes. Save them before leaving, or discard them and continue.",
currentStep: isPolicyView ? "policies" : "global-settings",
unsavedTitle: isPolicyView ? "Unsaved campaign policy changes" : "Unsaved campaign settings",
unsavedMessage: isPolicyView
? "Campaign policies have unsaved changes. Save them before leaving, or discard them and continue."
: "Campaign settings have unsaved changes. Save them before leaving, or discard them and continue.",
extraPayload: () => ({ editor_state: editorState }),
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
@@ -50,8 +61,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
const optIns = asRecord(editorState.opt_ins);
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
const pageTitle = isPolicyView ? "Campaign policies" : "Campaign settings";
function patchEditor(path: string[], value: unknown) {
if (locked) return;
@@ -59,12 +69,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
markDirty();
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Campaign settings</PageTitle>
<PageTitle loading={loading}>{pageTitle}</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
@@ -78,81 +87,161 @@ export default function GlobalSettingsPage({ settings, auth, campaignId }: { set
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
{isPolicyView ? (
<>
{canReadRetentionPolicy && (
<RetentionPolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
title="Campaign retention policy"
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
canWrite={canWriteRetentionPolicy}
locked={locked}
/>
)}
{canReadRetentionPolicy && (
<RetentionPolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
title="Campaign retention policy"
description="Campaign-level retention limits applied after system, tenant and owner policy. Blank values inherit."
canWrite={canWriteRetentionPolicy}
locked={locked}
/>
)}
<div className="dashboard-grid below-grid">
<Card title="Validation policy" collapsible>
<PolicyTable>
<PolicyRow
label="Missing required attachment"
note="Required attachment rule found no matching file."
control={<PolicySelectControl value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "missing_required_attachment", "ask"))}
/>
<PolicyRow
label="Missing optional attachment"
note="Optional attachment rule found no matching file."
control={<PolicySelectControl value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "missing_optional_attachment", "warn"))}
/>
<PolicyRow
label="Ambiguous attachment match"
note="Attachment rule matched more files than expected."
control={<PolicySelectControl value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "ambiguous_attachment_match", "ask"))}
/>
<PolicyRow
label="Unsent attachment file"
note="A watched attachment directory contains files that were not selected."
control={<PolicySelectControl value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />}
effective={behaviorSummary(getText(validationPolicy, "unsent_attachment_files", "warn"))}
/>
<PolicyRow
label="Missing email address"
note="The effective recipient list has no To address."
control={<PolicySelectControl value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />}
effective={behaviorSummary(getText(validationPolicy, "missing_email", "block"))}
/>
<PolicyRow
label="Template error"
note="A selected template body contains unresolved placeholders."
control={<PolicySelectControl value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />}
effective={behaviorSummary(getText(validationPolicy, "template_error", "block"))}
/>
<PolicyRow
label="Ignore empty fields"
note="Controls how missing placeholder values are rendered."
control={<ToggleSwitch label="Enabled" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />}
effective={getBool(validationPolicy, "ignore_empty_fields") ? "Missing values render as empty text." : "Missing values remain visible and can trigger template errors."}
/>
</PolicyTable>
</Card>
<div className="dashboard-grid">
<Card title="Validation policy">
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
<PolicySelect label="Unsent attachment file" value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />
</Card>
<Card title="Attachment policy" collapsible>
<PolicyTable>
<PolicyRow
label="Default missing behavior"
note="Used by attachment rules that do not override missing-file behavior."
control={<PolicySelectControl value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "missing_behavior"], value)} />}
effective={behaviorSummary(getText(attachments, "missing_behavior", "ask"))}
/>
<PolicyRow
label="Default ambiguous behavior"
note="Used by attachment rules that do not override ambiguous-match behavior."
control={<PolicySelectControl value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(value) => patch(["attachments", "ambiguous_behavior"], value)} />}
effective={behaviorSummary(getText(attachments, "ambiguous_behavior", "ask"))}
/>
<PolicyRow
label="Send without attachments"
note="Campaign-wide fallback when no attachment is available."
control={<ToggleSwitch label="Allowed" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
effective={getBool(attachments, "send_without_attachments", true) ? "Messages may be sent when attachment rules allow it." : "Missing attachment coverage blocks sending."}
/>
</PolicyTable>
</Card>
</div>
</>
) : (
<>
{data.campaign && <CampaignAccessCard settings={settings} campaign={data.campaign} onChanged={reload} onError={setError} />}
<Card title="Attachment defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Missing behavior">
<select value={getText(attachments, "missing_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<FormField label="Ambiguous behavior">
<select value={getText(attachments, "ambiguous_behavior", "ask")} disabled={locked} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select>
</FormField>
<ToggleSwitch label="Send without attachments" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />
</div>
<p className="muted small-note">The actual global and per-recipient attachment rules live in Attachments. These settings define campaign-wide behavior used by validation and review. Individual-attachment permission is configured per attachment base path.</p>
</Card>
</div>
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults" collapsible>
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</Card>
<div className="dashboard-grid below-grid">
<Card title="Delivery defaults">
<div className="form-grid compact responsive-form-grid">
<FormField label="Messages per minute"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
<FormField label="Concurrency"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
<FormField label="Max attempts"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
<ToggleSwitch label="Status tracking" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
</div>
</Card>
<Card title="Opt-ins and local assistance">
<div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
</Card>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
<Card title="Opt-ins and local assistance" collapsible>
<div className="toggle-grid">
<ToggleSwitch label="Suggest addresses from this campaign" checked={getBool(optIns, "campaign_address_suggestions", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "campaign_address_suggestions"], checked)} />
<ToggleSwitch label="Remember newly used addresses" checked={getBool(optIns, "remember_used_addresses")} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "remember_used_addresses"], checked)} />
<ToggleSwitch label="Show guided warnings while editing" checked={getBool(optIns, "inline_guidance", true)} disabled={locked} onChange={(checked) => patchEditor(["opt_ins", "inline_guidance"], checked)} />
</div>
<p className="muted small-note">These opt-ins are stored in the draft editor metadata for now. A later backend patch can make address-book storage tenant/user aware.</p>
</Card>
</div>
</>
)}
</LoadingFrame>
</div>
);
}
function PolicySelect({ label, value, disabled, onChange, options = behaviorOptions }: { label: string; value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
function PolicyTable({ children }: { children: ReactNode }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</FormField>
<div className="campaign-policy-table policy-table">
<div className="campaign-policy-row policy-row campaign-policy-row-header policy-row-header">
<span>Policy</span>
<span>Setting</span>
<span>Effective behavior</span>
</div>
{children}
</div>
);
}
function PolicyRow({ label, note, control, effective }: { label: string; note: string; control: ReactNode; effective: string }) {
return (
<div className="campaign-policy-row policy-row">
<div className="campaign-policy-label policy-field-label">
<strong>{label}</strong>
<small>{note}</small>
</div>
<div className="campaign-policy-control policy-control">{control}</div>
<p className="muted small-note campaign-policy-effective-note policy-effective-note">{effective}</p>
</div>
);
}
function PolicySelectControl({ value, disabled, onChange, options = behaviorOptions }: { value: string; disabled?: boolean; onChange: (value: string) => void; options?: string[] }) {
return (
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
);
}
function behaviorSummary(value: string): string {
if (value === "block") return "Blocks the affected message.";
if (value === "ask") return "Marks the message for review.";
if (value === "drop") return "Excludes the affected message.";
if (value === "continue") return "Continues without a review issue.";
if (value === "warn") return "Keeps sending possible with a warning.";
return "Uses the configured behavior.";
}

View File

@@ -1,15 +1,14 @@
import { useEffect, useState } from "react";
import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import { MailServerSettingsPanel, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } 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 DismissibleAlert from "../../components/DismissibleAlert";
import { MailProfilePolicyEditor } from "../mail/MailProfileManagement";
import { DismissibleAlert } from "@govoplan/core-webui";
import {
createMailServerProfile,
getMailProfilePolicy,
@@ -26,13 +25,25 @@ import {
} from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor";
import { campaignMailSettingsPolicyState } from "./policyUi";
const securityOptions = ["plain", "tls", "starttls"];
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
type MailSettingsView = "settings" | "policy";
type MailSettingsPageProps = {
settings: ApiSettings;
campaignId: string;
view?: MailSettingsView;
};
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
const mailModuleInstalled = usePlatformModuleInstalled("mail");
const isPolicyView = view === "policy";
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const MailProfilePolicyEditor = mailProfilesUi?.MailProfilePolicyEditor ?? null;
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
@@ -55,35 +66,84 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
locked,
reload,
setError,
currentStep: "mail-settings",
unsavedTitle: "Unsaved server settings",
unsavedMessage: "Server settings have unsaved changes. Save them before leaving, or discard them and continue."
currentStep: isPolicyView ? "mail-policy" : "mail-settings",
unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings",
unsavedMessage: isPolicyView
? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue."
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue."
});
const server = asRecord(displayDraft.server);
const smtp = asRecord(server.smtp);
const imap = asRecord(server.imap);
const credentials = asRecord(server.credentials);
const smtpCredentials = asRecord(credentials.smtp);
const imapCredentials = asRecord(credentials.imap);
const delivery = asRecord(displayDraft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
const imapEnabled = getBool(imap, "enabled");
const selectedProfileId = getText(server, "mail_profile_id");
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
const usingMailProfile = Boolean(selectedProfileId);
const selectedProfileId = mailModuleInstalled ? getText(server, "mail_profile_id") : "";
const selectedProfile = mailModuleInstalled ? mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null : null;
const usingMailProfile = mailModuleInstalled && Boolean(selectedProfileId);
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
const effectiveImapAvailable = usingMailProfile ? selectedProfileHasImap : imapEnabled;
const imapUnavailable = usingMailProfile && !selectedProfileHasImap;
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicy, selectedProfileId, locked });
const effectiveMailPolicyForState: MailProfilePolicy | null = mailModuleInstalled ? effectiveMailPolicy : { allow_campaign_profiles: true };
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicyForState, selectedProfileId, locked });
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited);
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile || !imapEnabled;
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited);
const imapDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable;
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile;
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || (usingMailProfile && imapCredentialsInherited);
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
const imapAppendEnabled = getBool(imapAppend, "enabled");
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
const inlinePolicyMessages = useMemo(() => {
const validateMailPolicy = mailProfilesUi?.validateMailPolicy;
if (!mailModuleInstalled || usingMailProfile || !validateMailPolicy || !effectiveMailPolicy) return [];
const recipients = asRecord(displayDraft.recipients);
const fromEmail = firstAddressEmail(recipients.from);
return validateMailPolicy(effectiveMailPolicy, {
smtpHost: getText(smtp, "host"),
imapHost: getText(imap, "host"),
envelopeSender: fromEmail || getText(smtpCredentials, "username", getText(smtp, "username")),
fromHeader: fromEmail,
recipientDomains: collectRecipientDomains(displayDraft)
});
}, [displayDraft, effectiveMailPolicy, imap, mailModuleInstalled, mailProfilesUi, smtp, smtpCredentials, usingMailProfile]);
const displayedSmtp = {
host: usingMailProfile && selectedProfile ? stringOrEmpty(selectedProfile.smtp.host) : getText(smtp, "host"),
port: usingMailProfile && selectedProfile ? selectedProfile.smtp.port ?? 587 : getNumber(smtp, "port", 587),
username: usingMailProfile && smtpCredentialsInherited ? profileUsername(selectedProfile, "smtp") : getText(smtpCredentials, "username", getText(smtp, "username")),
password: usingMailProfile && smtpCredentialsInherited ? "" : getText(smtpCredentials, "password", getText(smtp, "password")),
security: usingMailProfile && selectedProfile ? selectedProfile.smtp.security ?? "starttls" : getText(smtp, "security", "starttls"),
timeout_seconds: usingMailProfile && selectedProfile ? selectedProfile.smtp.timeout_seconds ?? 30 : getNumber(smtp, "timeout_seconds", 30)
};
const displayedImap = {
host: usingMailProfile && selectedProfile?.imap ? stringOrEmpty(selectedProfile.imap.host) : getText(imap, "host"),
port: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.port ?? 993 : getNumber(imap, "port", 993),
username: usingMailProfile && imapCredentialsInherited ? profileUsername(selectedProfile, "imap") : getText(imapCredentials, "username", getText(imap, "username")),
password: usingMailProfile && imapCredentialsInherited ? "" : getText(imapCredentials, "password", getText(imap, "password")),
security: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.security ?? "tls" : getText(imap, "security", "tls"),
sent_folder: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.sent_folder ?? "auto" : getText(imap, "sent_folder", "auto"),
timeout_seconds: usingMailProfile && selectedProfile?.imap ? selectedProfile.imap.timeout_seconds ?? 30 : getNumber(imap, "timeout_seconds", 30)
};
const selectedProfileNeedsLocalCredentials = usingMailProfile && (!smtpCredentialsInherited || (selectedProfileHasImap && !imapCredentialsInherited));
useEffect(() => { void refreshMailProfiles(); }, [settings.apiBaseUrl, settings.apiKey, campaignId]);
useEffect(() => {
if (!mailModuleInstalled) {
setMailProfiles([]);
setPolicyProfiles([]);
setEffectiveMailPolicy({ allow_campaign_profiles: true });
setProfileError("");
setProfilesLoading(false);
return;
}
void refreshMailProfiles();
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, mailModuleInstalled]);
async function refreshMailProfiles() {
if (!mailModuleInstalled) return;
setProfilesLoading(true);
setProfileError("");
try {
@@ -106,7 +166,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
function selectMailProfile(profileId: string) {
if (locked) return;
if (!mailModuleInstalled || locked) return;
if (!profileId && !campaignProfilesAllowed) {
setProfileError(mailPolicyState.inlineBlockedMessage);
return;
@@ -117,6 +177,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
if (profileId) {
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
patch(["server", "credentials"], {});
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
@@ -124,7 +185,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
async function saveCurrentSettingsAsProfile() {
if (locked || usingMailProfile || !campaignProfilesAllowed) return;
if (!mailModuleInstalled || locked || usingMailProfile || !campaignProfilesAllowed) return;
setProfilesLoading(true);
setProfileMessage("");
setProfileError("");
@@ -133,14 +194,16 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
name: profileName.trim() || `${data.campaign?.name || "Campaign"} mail profile`,
scope_type: "campaign",
scope_id: campaignId,
smtp: smtpPayload(),
imap: imapEnabled ? imapPayload() : null,
smtp: smtpServerPayload(),
imap: hasInlineImapSettings() ? imapServerPayload() : null,
credentials: mailProfileCredentialsPayload(false),
is_active: true
});
setMailProfiles((current) => [...current.filter((profile) => profile.id !== created.id), created].sort((a, b) => a.name.localeCompare(b.name)));
patch(["server", "mail_profile_id"], created.id);
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
patch(["server", "credentials"], {});
setProfileName("");
setProfileMessage(`Saved profile ${created.name}.`);
} catch (err) {
@@ -150,30 +213,30 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
}
function toggleImap(enabled: boolean) {
patch(["server", "imap", "enabled"], enabled);
if (!enabled) {
patch(["delivery", "imap_append_sent", "enabled"], false);
}
}
function patchSmtpSettings(patchValue: Partial<MailServerSmtpSettings>) {
if (patchValue.host !== undefined) patch(["server", "smtp", "host"], String(patchValue.host ?? ""));
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], Number(patchValue.port || 0));
if (patchValue.username !== undefined) patch(["server", "smtp", "username"], String(patchValue.username ?? ""));
if (patchValue.password !== undefined) patch(["server", "smtp", "password"], String(patchValue.password ?? ""));
if (patchValue.port !== undefined) patch(["server", "smtp", "port"], mailNumberOrNull(patchValue.port));
if (patchValue.security !== undefined) patch(["server", "smtp", "security"], String(patchValue.security || "starttls"));
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], Number(patchValue.timeout_seconds || 0));
if (patchValue.timeout_seconds !== undefined) patch(["server", "smtp", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
}
function patchImapSettings(patchValue: Partial<MailServerImapSettings>) {
if (patchValue.host !== undefined) patch(["server", "imap", "host"], String(patchValue.host ?? ""));
if (patchValue.port !== undefined) patch(["server", "imap", "port"], Number(patchValue.port || 0));
if (patchValue.username !== undefined) patch(["server", "imap", "username"], String(patchValue.username ?? ""));
if (patchValue.password !== undefined) patch(["server", "imap", "password"], String(patchValue.password ?? ""));
if (patchValue.port !== undefined) patch(["server", "imap", "port"], mailNumberOrNull(patchValue.port));
if (patchValue.security !== undefined) patch(["server", "imap", "security"], String(patchValue.security || "tls"));
if (patchValue.sent_folder !== undefined) patch(["server", "imap", "sent_folder"], String(patchValue.sent_folder ?? ""));
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], Number(patchValue.timeout_seconds || 0));
if (patchValue.timeout_seconds !== undefined) patch(["server", "imap", "timeout_seconds"], mailNumberOrDefault(patchValue.timeout_seconds, 30));
}
function patchSmtpCredentials(patchValue: Partial<MailServerCredentialSettings>) {
if (patchValue.username !== undefined) patch(["server", "credentials", "smtp", "username"], String(patchValue.username ?? ""));
if (patchValue.password !== undefined) patch(["server", "credentials", "smtp", "password"], String(patchValue.password ?? ""));
}
function patchImapCredentials(patchValue: Partial<MailServerCredentialSettings>) {
if (patchValue.username !== undefined) patch(["server", "credentials", "imap", "username"], String(patchValue.username ?? ""));
if (patchValue.password !== undefined) patch(["server", "credentials", "imap", "password"], String(patchValue.password ?? ""));
}
function profileScopeLabel(profile: MailServerProfile): string {
@@ -185,71 +248,60 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;
return normalized ? normalized : null;
function smtpServerPayload() {
return mailSmtpSettingsPayload<MailSecurity>(
{ host: getText(smtp, "host"), port: getNumber(smtp, "port", 587), security: getText(smtp, "security", "starttls"), timeout_seconds: getNumber(smtp, "timeout_seconds", 30) },
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions },
);
}
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
function imapServerPayload() {
return mailImapSettingsPayload<MailSecurity>(
{ host: getText(imap, "host"), port: getNumber(imap, "port", 993), security: getText(imap, "security", "tls"), sent_folder: getText(imap, "sent_folder", "auto"), timeout_seconds: getNumber(imap, "timeout_seconds", 30) },
{ fallbackSecurity: "tls", allowedSecurity: securityOptions },
);
}
function smtpPayload() {
function mailProfileCredentialsPayload(preserveBlankPassword: boolean) {
return {
host: emptyToNull(getText(smtp, "host")),
port: getNumber(smtp, "port", 587),
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false),
security: readSecurity(getText(smtp, "security", "starttls"), "starttls"),
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
smtp: mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, preserveBlankPassword),
imap: mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, preserveBlankPassword),
};
}
function imapPayload() {
return {
enabled: true,
host: emptyToNull(getText(imap, "host")),
port: getNumber(imap, "port", 993),
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false),
security: readSecurity(getText(imap, "security", "tls"), "tls"),
sent_folder: emptyToNull(getText(imap, "sent_folder", "auto")),
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
};
function rawSmtpPayload() {
const serverPayload = selectedProfile && !smtpCredentialsInherited ? selectedProfile.smtp : smtpServerPayload();
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(smtpCredentials, smtp, false) };
}
function effectiveSmtpPayload() {
if (selectedProfile && !smtpCredentialsInherited) {
return {
...selectedProfile.smtp,
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false)
};
}
return smtpPayload();
function rawImapPayload() {
const serverPayload = selectedProfile?.imap && !imapCredentialsInherited ? selectedProfile.imap : imapServerPayload();
return { ...serverPayload, ...mailTransportCredentialsPayloadFromRecords(imapCredentials, imap, false) };
}
function effectiveImapPayload() {
if (selectedProfile?.imap && !imapCredentialsInherited) {
return {
...selectedProfile.imap,
enabled: true,
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false)
};
}
return imapPayload();
function hasInlineImapSettings(): boolean {
return hasMailImapSettings([getText(imap, "host"), getText(imapCredentials, "username", getText(imap, "username")), getText(imapCredentials, "password", getText(imap, "password"))]);
}
function profileUsername(profile: MailServerProfile | null, protocol: "smtp" | "imap"): string {
if (!profile) return "";
if (protocol === "smtp") return stringOrEmpty(profile.credentials?.smtp?.username ?? profile.smtp.username);
return stringOrEmpty(profile.credentials?.imap?.username ?? profile.imap?.username);
}
async function runSmtpTest() {
if (!mailModuleInstalled) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: "Install and enable the Mail module to test SMTP settings.", details: {} });
return;
}
if (locked || inlineMailSettingsBlocked) return;
setMailActionState("smtp");
setLocalError("");
try {
setSmtpTestResult(selectedProfileId && smtpCredentialsInherited
? await testMailProfileSmtp(settings, selectedProfileId)
: await testSmtpSettings(settings, effectiveSmtpPayload()));
: await testSmtpSettings(settings, rawSmtpPayload()));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
@@ -258,13 +310,17 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
async function runImapTest() {
if (!mailModuleInstalled) {
setImapTestResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to test IMAP settings.", details: {} });
return;
}
if (imapDisabled) return;
setMailActionState("imap");
setLocalError("");
try {
setImapTestResult(selectedProfileId && imapCredentialsInherited
? await testMailProfileImap(settings, selectedProfileId)
: await testImapSettings(settings, effectiveImapPayload()));
: await testImapSettings(settings, rawImapPayload()));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
@@ -273,13 +329,17 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
async function runFolderLookup() {
if (imapDisabled) return;
if (!mailModuleInstalled) {
setFolderResult({ ok: false, protocol: "imap", message: "Install and enable the Mail module to inspect IMAP folders.", folders: [], details: {} });
return;
}
if (appendTargetFolderDisabled) return;
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(selectedProfileId && imapCredentialsInherited
? await listMailProfileImapFolders(settings, selectedProfileId)
: await listImapFolders(settings, effectiveImapPayload()));
: await listImapFolders(settings, rawImapPayload()));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
@@ -289,25 +349,20 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || imapDisabled) return;
if (!usingMailProfile) {
patch(["server", "imap", "sent_folder"], folder);
}
if (!getText(imapAppend, "folder") || getText(imapAppend, "folder") === "auto") {
patch(["delivery", "imap_append_sent", "folder"], folder);
}
if (!folder || appendTargetFolderDisabled) return;
patch(["delivery", "imap_append_sent", "folder"], folder);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Server settings</PageTitle>
<PageTitle loading={loading}>{isPolicyView ? "Mail policy" : "Mail settings"}</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>}
</div>
</div>
@@ -317,21 +372,34 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<MailProfilePolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
campaignId={campaignId}
profiles={policyProfiles}
ownerUserId={data.campaign?.owner_user_id}
ownerGroupId={data.campaign?.owner_group_id}
canWrite={!locked}
locked={locked}
title="Campaign-local mail policy"
description="Campaign-local mail limits applied after system, tenant and owner policies."
onSaved={refreshMailProfiles}
/>
{!mailModuleInstalled && (
<DismissibleAlert tone="info" dismissible={false}>
The Mail module is not installed. Inline SMTP and IMAP values can be edited in the campaign draft, but reusable profiles, connection tests and sending require the Mail module.
</DismissibleAlert>
)}
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && (
<MailProfilePolicyEditor
settings={settings}
scopeType="campaign"
scopeId={campaignId}
campaignId={campaignId}
profiles={policyProfiles}
ownerUserId={data.campaign?.owner_user_id}
ownerGroupId={data.campaign?.owner_group_id}
canWrite={!locked}
locked={locked}
title="Campaign-local mail policy"
description="Campaign-local mail limits applied after system, tenant and owner policies."
onSaved={refreshMailProfiles}
/>
)}
{isPolicyView && mailModuleInstalled && !MailProfilePolicyEditor && (
<DismissibleAlert tone="warning" dismissible={false}>The Mail module did not expose profile-management UI capabilities.</DismissibleAlert>
)}
{!isPolicyView && mailModuleInstalled && (
<Card
title="Reusable mail profile"
actions={
@@ -356,45 +424,44 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
{selectedProfile && (
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p>
)}
{selectedProfileNeedsLocalCredentials && (
<p className="muted small-note">The selected profile supplies the server settings. Enter the required campaign-local credentials in the mail server settings panel below.</p>
)}
{profileMessage && <DismissibleAlert tone="success" resetKey={profileMessage} floating>{profileMessage}</DismissibleAlert>}
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissStorageKey={`campaign:${campaignId}:mail-settings:profile-error`} floating>{profileError}</DismissibleAlert>}
</Card>
)}
<Card title="Mail server settings">
{!isPolicyView && (
<Card title="Mail server settings" collapsible>
{inlinePolicyMessages.length > 0 && (
<DismissibleAlert tone="warning" resetKey={inlinePolicyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
<strong>Effective mail policy blocks the current inline settings.</strong>
<ul>{inlinePolicyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
</DismissibleAlert>
)}
<MailServerSettingsPanel
smtp={{
host: getText(smtp, "host"),
port: getNumber(smtp, "port", 587),
username: getText(smtp, "username"),
password: getText(smtp, "password"),
security: getText(smtp, "security", "starttls"),
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
}}
imap={{
enabled: imapEnabled,
host: getText(imap, "host"),
port: getNumber(imap, "port", 993),
username: getText(imap, "username"),
password: getText(imap, "password"),
security: getText(imap, "security", "tls"),
sent_folder: getText(imap, "sent_folder", "auto"),
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
}}
smtp={displayedSmtp}
imap={displayedImap}
onSmtpChange={patchSmtpSettings}
onImapChange={patchImapSettings}
onImapEnabledChange={toggleImap}
smtpCredentials={{ username: displayedSmtp.username, password: displayedSmtp.password }}
imapCredentials={{ username: displayedImap.username, password: displayedImap.password }}
onSmtpCredentialsChange={patchSmtpCredentials}
onImapCredentialsChange={patchImapCredentials}
smtpDisabled={smtpDisabled}
smtpCredentialDisabled={smtpCredentialDisabled}
smtpActionDisabled={locked || inlineMailSettingsBlocked}
imapToggleDisabled={locked || usingMailProfile || inlineMailSettingsBlocked}
smtpPasswordSaved={Boolean(usingMailProfile && smtpCredentialsInherited && selectedProfile?.smtp_password_configured)}
smtpActionDisabled={locked || inlineMailSettingsBlocked || !mailModuleInstalled}
imapServerDisabled={imapServerDisabled}
imapCredentialDisabled={imapCredentialDisabled}
imapActionDisabled={imapDisabled}
imapPasswordSaved={Boolean(usingMailProfile && imapCredentialsInherited && selectedProfile?.imap_password_configured)}
imapActionDisabled={imapDisabled || !mailModuleInstalled}
append={{
enabled: getBool(imapAppend, "enabled"),
enabled: imapAppendEnabled,
folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")),
disabled: imapDisabled,
folderDisabled: imapDisabled || !getBool(imapAppend, "enabled"),
folderDisabled: appendTargetFolderDisabled,
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
}}
@@ -408,17 +475,44 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
imapTestResult={imapTestResult}
folderLookupResult={folderResult}
onUseDetectedFolder={useDetectedSentFolder}
useDetectedFolderDisabled={imapDisabled}
useDetectedFolderDisabled={appendTargetFolderDisabled}
floatingResults
/>
</Card>
)}
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || inlineMailSettingsBlocked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
);
}
function stringOrEmpty(value: unknown): string {
return value === null || value === undefined ? "" : String(value);
}
function firstAddressEmail(value: unknown): string {
return addressesFromValue(value)[0]?.email ?? "";
}
function collectRecipientDomains(draft: Record<string, unknown>): string[] {
const domains = new Set<string>();
const recipients = asRecord(draft.recipients);
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, recipients[key]);
const entries = asArray(asRecord(draft.entries).inline).map(asRecord);
for (const entry of entries) {
for (const key of ["to", "cc", "bcc"]) addAddressDomains(domains, entry[key]);
addAddressDomains(domains, entry.recipient);
addAddressDomains(domains, entry);
}
return [...domains].sort();
}
function addAddressDomains(domains: Set<string>, value: unknown) {
for (const address of addressesFromValue(value)) {
const domain = address.email.split("@").pop()?.trim().toLowerCase();
if (domain) domains.add(domain);
}
}

View File

@@ -1,15 +1,15 @@
import { useMemo } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } 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 EmailAddressInput from "../../components/email/EmailAddressInput";
import DismissibleAlert from "../../components/DismissibleAlert";
import { ToggleSwitch } from "@govoplan/core-webui";
import { EmailAddressInput } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
@@ -19,9 +19,8 @@ import {
addressesFromValue,
collectCampaignAddressSuggestions,
type MailboxAddress
} from "../../utils/emailAddresses";
import { insertAfter, moveArrayItem } from "../../utils/arrayOrder";
} from "@govoplan/core-webui";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
const recipientHeaderRows = [
{ key: "to", label: "To", toggleKey: "allow_individual_to", toggleLabel: "Allow individual To", addLabel: "Add recipient", emptyText: "No global recipients configured." },
{ key: "cc", label: "CC", toggleKey: "allow_individual_cc", toggleLabel: "Allow individual CC", addLabel: "Add CC", emptyText: "No global CC recipients configured." },
@@ -169,7 +168,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Campaign sender">
<Card title="Campaign sender" collapsible>
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="Default From address">
@@ -217,7 +216,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
</Card>
<Card title="Global recipient headers">
<Card title="Global recipient headers" collapsible>
<div className="campaign-header-stack">
{recipientHeaderRows.map((row) => (
<div className="campaign-header-grid" key={row.key}>
@@ -245,11 +244,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
</Card>
<section className="recipient-profiles-section">
<div className="subsection-heading split">
<h3>Recipient profiles</h3>
<Button disabled>Import</Button>
</div>
<Card title="Recipient profiles" actions={<Button disabled>Import</Button>}>
{inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
)}
@@ -266,11 +261,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
/>
</div>
)}
</section>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</Card>
</>
</LoadingFrame>
</div>

View File

@@ -1,10 +1,11 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import { 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 { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
@@ -14,12 +15,13 @@ import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { addressesFromValue } from "../../utils/emailAddresses";
import DismissibleAlert from "../../components/DismissibleAlert";
import { addressesFromValue } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const filesModuleInstalled = usePlatformModuleInstalled("files");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const version = data.currentVersion;
@@ -94,20 +96,18 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert>
)}
{inlineEntries.length > 0 && (
<DataGrid
id={`campaign-${campaignId}-recipient-data`}
rows={inlineEntries.slice(0, 100)}
columns={recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient data found."
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
/>
<div className="admin-table-surface recipient-data-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-data`}
rows={inlineEntries.slice(0, 100)}
columns={recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient data found."
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
/>
</div>
)}
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
</LoadingFrame>
</div>
@@ -118,6 +118,7 @@ type RecipientDataColumnContext = {
settings: ApiSettings;
campaignId: string;
locked: boolean;
filesModuleInstalled: boolean;
fieldDefinitions: ReturnType<typeof getDraftFields>;
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
zipConfig: AttachmentZipCollection;
@@ -125,7 +126,7 @@ type RecipientDataColumnContext = {
updateEntryField: (index: number, field: string, value: unknown) => void;
};
function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
function recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
{
@@ -161,6 +162,7 @@ function recipientDataColumns({ settings, campaignId, locked, fieldDefinitions,
buttonLabel={`entries: ${attachments.length}`}
basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
);

View File

@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import type { ApiSettings } from "../../types";
import {
appendSent,
buildVersion,
getCampaignJobs,
getCampaignJobDetail,
@@ -26,16 +27,16 @@ import {
type CampaignVersionDetail,
} from "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import Button from "../../components/Button";
import { Button, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
import DismissibleAlert from "../../components/DismissibleAlert";
import ConfirmDialog from "../../components/ConfirmDialog";
import LoadingFrame from "../../components/LoadingFrame";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import ToggleSwitch from "../../components/ToggleSwitch";
import InlineHelp from "../../components/help/InlineHelp";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { InlineHelp } from "@govoplan/core-webui";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
@@ -77,7 +78,7 @@ type FlowStageDefinition = {
lockReason?: string;
};
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "";
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "imap" | "";
const stateColors: Record<FlowState, string> = {
complete: "var(--green)",
@@ -103,6 +104,12 @@ const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "ex
export default function ReviewSendPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const navigate = useNavigate();
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
const mockWorkflowAvailable = devMailboxCapability?.enabled === true;
const [mockVerificationRequired, setMockVerificationRequired] = useState(true);
const [mockMailboxPreviewEnabled, setMockMailboxPreviewEnabled] = useState(true);
const mockWorkflowRequired = mockWorkflowAvailable && mockVerificationRequired;
const mockMailboxPreviewActive = mockWorkflowAvailable && mockMailboxPreviewEnabled;
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null);
const [queueStatusLoading, setQueueStatusLoading] = useState(false);
@@ -140,6 +147,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const [dryRun, setDryRun] = useState(false);
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
const [imapAppendResult, setImapAppendResult] = useState<Record<string, unknown> | null>(null);
const [imapDiagnostics, setImapDiagnostics] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState<Record<string, unknown> | null>(null);
const persistedReview = storedMessageReviewState(version);
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}`;
@@ -153,6 +163,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
setMockResult(null);
setSelectedMockMessage(null);
setSendResult(null);
setImapAppendResult(null);
setImapDiagnostics(emptyCampaignJobsResponse());
setSelectedDeliveryJobDetail(null);
setSendConfirmOpen(false);
setLiveSummary(null);
}, [version?.id]);
@@ -162,10 +175,16 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
}, [version?.id, persistedReviewKey]);
useEffect(() => {
if (!mockMailboxPreviewActive) setSelectedMockMessage(null);
}, [mockMailboxPreviewActive]);
const validationPresent = Object.keys(validation).length > 0;
const validationOk = validation.ok === true;
const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]);
const validationWarnings = numberFrom(validation, ["warning_count", "warnings"]);
const validationIssues = asArray(validation.issues).map(asRecord);
const visibleValidationIssues = validationIssues.slice(0, 10);
const readyForDelivery = isVersionReadyForDelivery(version);
const validationStale = validationOk && !readyForDelivery;
@@ -185,6 +204,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const statusCounts = asRecord(summary?.status_counts);
const sendStatusCounts = asRecord(statusCounts.send);
const imapStatusCounts = asRecord(statusCounts.imap);
const attempts = asRecord(summary?.attempts);
const summaryDelivery = asRecord(summary?.delivery);
const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]);
@@ -204,6 +224,8 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const failedCount = cards?.failed ?? 0;
const imapAppended = cards?.imap_appended ?? 0;
const imapFailed = cards?.imap_failed ?? 0;
const imapPending = numberFrom(imapStatusCounts, ["pending"]);
const imapSkipped = numberFrom(imapStatusCounts, ["skipped"]);
const deliveryHasTerminalOutcome = sentCount + failedCount + outcomeUnknownCount + cancelledCount > 0;
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
const deliverySending = activeSendCount > 0 || (currentWorkflowState === "sending" && queuedOrActiveCount > 0);
@@ -294,6 +316,13 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const mockMailbox = asRecord(mockResult?.mailbox);
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
const sendResultRows = asArray(sendResult?.results).map(asRecord);
const imapAppendResultRows = asArray(imapAppendResult?.results).map(asRecord);
const imapDiagnosticRows = imapDiagnostics.jobs.map(asRecord);
const imapDiagnosticsPending = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "pending").length;
const imapDiagnosticsFailed = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "failed").length;
const imapPendingForDisplay = Math.max(imapPending, imapDiagnosticsPending);
const imapFailedForDisplay = Math.max(imapFailed, imapDiagnosticsFailed);
const canAppendPendingImap = Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && !historicalVersion && !userLockedVersion;
const validationReviewState: FlowState = busy === "validate"
? "running"
@@ -322,21 +351,29 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
? "complete"
: "active";
const mockState: FlowState = !inspectionSatisfied
const mockState: FlowState = !mockWorkflowAvailable
? "locked"
: busy === "mock" || busy === "mailbox"
? "running"
: mockPartial
? "partial"
: mockFailed > 0
? "danger"
: mockComplete
? "complete"
: downstreamDeliveryActivity
? "warning"
: "active";
: !inspectionSatisfied
? "locked"
: busy === "mock" || busy === "mailbox"
? "running"
: mockPartial
? "partial"
: mockFailed > 0
? "danger"
: mockComplete
? "complete"
: downstreamDeliveryActivity
? "warning"
: "active";
const mockGateSatisfied = mockComplete || downstreamDeliveryActivity;
const mockStateDisplayLabel = !mockWorkflowAvailable ? "Unavailable" : !mockVerificationRequired ? "Optional" : stateLabel(mockState);
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || downstreamDeliveryActivity);
const sendLockReason = !inspectionSatisfied
? "Build and complete the required message review first."
: mockWorkflowRequired
? "Complete a successful mock delivery first."
: "Build the exact queue first.";
const sendState: FlowState = !mockGateSatisfied
? "locked"
: busy === "send" || deliverySending
@@ -391,11 +428,11 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
id: "workflow-mock-verify",
title: "Mock send and verify",
shortTitle: "Mock send",
description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers.",
description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers. This dev workflow can be optional before real sending.",
icon: FlaskConical,
state: mockState,
stateLabel: stateLabel(mockState),
lockReason: "Build and complete the required message review first.",
stateLabel: mockStateDisplayLabel,
lockReason: mockWorkflowAvailable ? "Build and complete the required message review first." : "Enable the Mail dev mailbox capability to run mock delivery.",
},
{
id: "workflow-send",
@@ -405,7 +442,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
icon: Send,
state: sendState,
stateLabel: stateLabel(sendState),
lockReason: "Complete a successful mock delivery first.",
lockReason: sendLockReason,
},
{
id: "workflow-results",
@@ -424,6 +461,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
sendState,
resultState,
validationErrors,
mockStateDisplayLabel,
mockWorkflowAvailable,
sendLockReason,
]);
async function runValidation() {
@@ -502,7 +542,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
}
async function runMockSend() {
if (!version || busy || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
if (!version || busy || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
setBusy("mock");
setMessage("Running the complete mock-delivery flow…");
setError("");
@@ -571,6 +611,73 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
}
}
async function runAppendSent() {
if (!version || busy || !canAppendPendingImap) return;
const runInline = !backgroundWorkersEnabled;
setBusy("imap");
setError("");
setMessage(runInline ? "Appending pending Sent copies via IMAP..." : "Queueing pending IMAP append jobs...");
try {
const response = await appendSent(settings, campaignId, {
enqueue_celery: backgroundWorkersEnabled,
run_inline: runInline,
dry_run: false,
});
const result = asRecord(response.result ?? response);
setImapAppendResult(result);
const appended = numberFrom(result, ["appended_count"]);
const failed = numberFrom(result, ["failed_count"]);
const enqueued = numberFrom(result, ["enqueued_count"]);
const pending = numberFrom(result, ["pending_count"]);
setMessage(runInline
? `IMAP append processed ${pending} pending job(s): appended ${appended}, failed ${failed}.`
: `Queued ${enqueued} pending IMAP append job(s).`);
await refreshQueueStatus(true);
await loadImapDiagnostics(true);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function loadImapDiagnostics(silent = false) {
if (!version?.id) return;
setBusy("inspect");
if (!silent) setMessage("Loading IMAP diagnostics...");
setError("");
try {
const result = await getCampaignJobs(settings, campaignId, {
versionId: version.id,
page: 1,
pageSize: 50,
imapStatus: ["pending", "failed"],
});
setImapDiagnostics(result);
if (!silent) setMessage(`Loaded ${result.total} pending/failed IMAP job(s).`);
} catch (err) {
if (!silent) setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function openDeliveryJobDetail(jobId: string) {
if (!jobId || busy) return;
setBusy("inspect");
setError("");
try {
const detail = await getCampaignJobDetail(settings, campaignId, jobId);
setSelectedDeliveryJobDetail(detail as unknown as Record<string, unknown>);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function completeInspection(_acceptBulk = false) {
if (!version || busy || readOnlyVersion || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
setBusy("inspect");
@@ -605,7 +712,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
}
async function openMockMessage(id: string) {
if (!id || busy === "mailbox") return;
if (!id || busy === "mailbox" || !mockMailboxPreviewActive) return;
setBusy("mailbox");
setError("");
try {
@@ -724,6 +831,24 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
{validationErrors > 0 && (
<p className="review-flow-inline-note is-danger">Resolve the blocking entries, then validate again.</p>
)}
{visibleValidationIssues.length > 0 && (
<div className="review-flow-data-section">
<h3>Validation details</h3>
<dl className="detail-list">
{visibleValidationIssues.map((issue, index) => (
<div key={`${String(issue.code ?? "issue")}:${index}`}>
<dt>{humanize(String(issue.severity ?? "issue"))}</dt>
<dd>
<strong>{String(issue.message ?? issue.code ?? "Validation issue")}</strong>
{issue.path ? <span className="muted"> · {String(issue.path)}</span> : null}
{issue.code ? <span className="muted"> · {String(issue.code)}</span> : null}
</dd>
</div>
))}
</dl>
{validationIssues.length > visibleValidationIssues.length && <p className="muted small-note">Showing {visibleValidationIssues.length} of {validationIssues.length} validation issue(s).</p>}
</div>
)}
<div className="button-row compact-actions review-flow-stage-actions">
<Button
variant="primary"
@@ -808,17 +933,22 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
<WorkflowFact label="Skipped" value={mockResult ? mockSkipped : "—"} />
<WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : "—"} />
<WorkflowFact label="Captured messages" value={!mockWorkflowAvailable ? "Unavailable" : mockResult ? mockMailboxMessages.length : "—"} />
</div>
<div className="button-row compact-actions review-flow-stage-actions">
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
</Button>
<Button onClick={() => navigate("../mail-settings")}>Review server settings</Button>
</div>
{!mockWorkflowAvailable && (
<p className="review-flow-inline-note is-stale">Mock delivery uses the Mail module development mailbox API. Enable that capability to run and inspect captured messages.</p>
)}
<div className="toggle-row mock-send-options">
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={Boolean(busy)} onChange={setMockClearFirst} />
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={Boolean(busy)} onChange={setMockAppendSent} />
<ToggleSwitch label="Require mock before real send" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
<ToggleSwitch label="Show captured mock mailbox" checked={mockMailboxPreviewEnabled && mockWorkflowAvailable} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockMailboxPreviewEnabled} />
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockClearFirst} />
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockAppendSent} />
</div>
{mockResult && (
<div className="review-flow-data-stack">
@@ -833,17 +963,24 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
className="data-table-wrap data-table compact-table"
/>
</section>
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-mock-mailbox`}
rows={mockMailboxMessages}
columns={mockMailboxColumns(openMockMessage)}
getRowKey={(row, index) => String(row.id ?? index)}
emptyText="No mock messages were captured in this run."
className="data-table-wrap data-table compact-table"
/>
</section>
{mockMailboxPreviewActive ? (
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-mock-mailbox`}
rows={mockMailboxMessages}
columns={mockMailboxColumns(openMockMessage)}
getRowKey={(row, index) => String(row.id ?? index)}
emptyText="No mock messages were captured in this run."
className="data-table-wrap data-table compact-table"
/>
</section>
) : (
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
<p className="muted small-note">{mockWorkflowAvailable ? "Captured mailbox preview is disabled for this run. Recipient outcomes remain available." : "Captured mailbox preview requires the Mail development mailbox API."}</p>
</section>
)}
</div>
)}
</WorkflowStage>
@@ -916,12 +1053,59 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
<WorkflowFact label="Queued / active" value={queuedOrActiveCount} />
<WorkflowFact label="Outcome unknown" value={outcomeUnknownCount} />
<WorkflowFact label="IMAP appended" value={imapAppended} />
<WorkflowFact label="IMAP failed" value={imapFailed} />
<WorkflowFact label="IMAP pending" value={imapPendingForDisplay} />
<WorkflowFact label="IMAP failed" value={imapFailedForDisplay} />
<WorkflowFact label="IMAP skipped" value={imapSkipped} />
</div>
<div className="review-flow-result-line">
<StatusBadge status={deliveryDisplayStatus} />
<span>{deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."}</span>
</div>
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && (
<p className="review-flow-inline-note is-stale">IMAP Sent append is still pending for {imapPendingForDisplay} job(s). Pending with no IMAP attempt usually means the append worker was not queued or is not running.</p>
)}
{Boolean(imapAppend.enabled) && imapFailedForDisplay > 0 && (
<p className="review-flow-inline-note is-danger">{imapFailedForDisplay} IMAP append job(s) failed. Load diagnostics to inspect the last error per job.</p>
)}
{Boolean(imapAppend.enabled) && (imapPendingForDisplay > 0 || imapFailedForDisplay > 0) && (
<div className="button-row compact-actions review-flow-stage-actions">
<Button onClick={() => void loadImapDiagnostics(false)} disabled={Boolean(busy)}>Load IMAP diagnostics</Button>
{imapPendingForDisplay > 0 && (
<Button variant="primary" onClick={() => void runAppendSent()} disabled={Boolean(busy) || !canAppendPendingImap}>
{busy === "imap" ? "Appending IMAP..." : backgroundWorkersEnabled ? "Queue pending IMAP append" : "Append pending IMAP now"}
</Button>
)}
</div>
)}
{imapAppendResult && (
<div className="review-flow-data-section">
<h3>IMAP append result</h3>
<p className="muted small-note">Pending {String(imapAppendResult.pending_count ?? "0")}, enqueued {String(imapAppendResult.enqueued_count ?? "0")}, processed {String(imapAppendResult.processed_count ?? "0")}, appended {String(imapAppendResult.appended_count ?? "0")}, failed {String(imapAppendResult.failed_count ?? "0")}.</p>
{imapAppendResultRows.length > 0 && (
<DataGrid
id={`campaign-${campaignId}-workflow-imap-append-results`}
rows={imapAppendResultRows}
columns={imapAppendResultColumns()}
getRowKey={(row, index) => String(row.job_id ?? index)}
emptyText="No IMAP append result rows returned."
className="data-table-wrap data-table compact-table"
/>
)}
</div>
)}
{imapDiagnostics.total > 0 && (
<div className="review-flow-data-section">
<h3>IMAP diagnostics</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-imap-diagnostics`}
rows={imapDiagnosticRows}
columns={imapDiagnosticColumns(openDeliveryJobDetail)}
getRowKey={(row, index) => String(row.id ?? index)}
emptyText="No pending or failed IMAP jobs found."
className="data-table-wrap data-table compact-table"
/>
</div>
)}
{recentFailures.length > 0 && (
<div className="review-flow-data-section">
<h3>Recent delivery failures</h3>
@@ -979,8 +1163,12 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
onConfirm={() => void runSendNow()}
/>
{selectedMockMessage && (
<MessagePreviewOverlay
{selectedDeliveryJobDetail && (
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
)}
{selectedMockMessage && mockMailboxPreviewActive && (
<CampaignMessagePreviewOverlay
title="Captured mock mail"
subject={selectedMockMessage.subject || "Mock message"}
bodyMode="text"
@@ -1134,7 +1322,7 @@ function BuiltMessagePreview({
const resolvedRecipients = asRecord(row.resolved_recipients);
return (
<MessagePreviewOverlay
<CampaignMessagePreviewOverlay
title="Built message review"
subject={subject}
bodyMode={html.trim() ? "html" : "text"}
@@ -1157,6 +1345,59 @@ function BuiltMessagePreview({
);
}
function DeliveryJobDetailOverlay({ detail, onClose }: { detail: Record<string, unknown>; onClose: () => void }) {
const job = asRecord(detail.job);
const attempts = asRecord(detail.attempts);
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
const imapAttempts = asArray(attempts.imap).map(asRecord);
const issues = asArray(job.issues).map(asRecord);
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="delivery-job-detail-title">
<div className="modal-panel template-preview-modal message-preview-modal">
<header className="modal-header">
<h2 id="delivery-job-detail-title">Delivery job details</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<dl className="detail-list">
<div><dt>Recipient</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
<div><dt>Subject</dt><dd>{String(job.subject ?? "-")}</dd></div>
<div><dt>SMTP status</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
<div><dt>IMAP status</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
{job.last_error ? <div><dt>Last error</dt><dd>{String(job.last_error)}</dd></div> : null}
</dl>
{issues.length > 0 && <AttemptList title="Message issues" rows={issues} />}
<AttemptList title="SMTP attempts" rows={smtpAttempts} emptyText="No SMTP attempts were recorded." />
<AttemptList title="IMAP attempts" rows={imapAttempts} emptyText="No IMAP append attempts were recorded. If status is pending, append has not run yet." />
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}
function AttemptList({ title, rows, emptyText = "No rows." }: { title: string; rows: Record<string, unknown>[]; emptyText?: string }) {
return (
<section className="review-flow-data-section">
<h3>{title}</h3>
{rows.length === 0 ? <p className="muted small-note">{emptyText}</p> : (
<dl className="detail-list">
{rows.map((row, index) => (
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
<dt>{String(row.status ?? row.severity ?? row.code ?? `#${index + 1}`)}</dt>
<dd>
<strong>{String(row.message ?? row.error_message ?? row.smtp_response ?? row.folder ?? row.path ?? "-")}</strong>
{row.started_at || row.created_at ? <span className="muted"> · {formatDateTime(String(row.started_at ?? row.created_at))}</span> : null}
{row.finished_at || row.updated_at ? <span className="muted"> {formatDateTime(String(row.finished_at ?? row.updated_at))}</span> : null}
</dd>
</div>
))}
</dl>
)}
</section>
);
}
function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="review-flow-fact">
@@ -1224,6 +1465,26 @@ function sendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
];
}
function imapAppendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "status", header: "Status", width: 150, sticky: "start", sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
{ id: "job", header: "Job", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? "-") },
{ id: "folder", header: "Folder", width: 190, sortable: true, filterable: true, value: (row) => String(row.folder ?? "-") },
{ id: "message", header: "Message", width: "minmax(300px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) },
];
}
function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "recipient", header: "Recipient", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
{ id: "send", header: "SMTP", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "IMAP", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "Last error", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>Details</Button> },
];
}
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "smtp", label: "SMTP" },
@@ -1243,16 +1504,17 @@ function builtMessageMetaItems(row: Record<string, unknown>) {
return [
{ label: "From", value: formatSingleAddress(recipients.from) || "—" },
{ label: "To", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
{ label: "CC", value: formatAddressList(recipients.cc) || "—" },
{ label: "BCC", value: formatAddressList(recipients.bcc) || "—" },
{ label: "CC", value: formatAddressList(recipients.cc) || null },
{ label: "BCC", value: formatAddressList(recipients.bcc) || null },
{ label: "Validation", value: String(row.validation_status ?? "—") },
{ label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" },
];
}
function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAttachment[] {
function builtMessageAttachments(row: Record<string, unknown>): CampaignMessagePreviewAttachment[] {
return asArray(row.attachments).flatMap((value, index) => {
const attachment = asRecord(value);
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
if (managedMatches.length > 0) {
return managedMatches.map((match, matchIndex) => ({
@@ -1262,6 +1524,8 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
sizeBytes: numberOrUndefined(match.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note,
}));
}
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
@@ -1273,6 +1537,8 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note,
}));
}
return [{
@@ -1282,14 +1548,36 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note,
}];
});
}
function zipProtectionFromBuiltAttachment(attachment: Record<string, unknown>): { protected: boolean; note: string | null } {
const zipFilename = stringOrUndefined(attachment.zip_filename);
if (!zipFilename) return { protected: false, note: null };
const legacyMode = String(attachment.password_mode ?? attachment.zip_password_mode ?? "").trim();
const protectedArchive = getBool(attachment, "password_enabled", getBool(attachment, "zip_password_protected", getBool(attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode))));
if (!protectedArchive) return { protected: false, note: null };
const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field);
const rawScope = String(attachment.password_scope ?? attachment.zip_password_scope ?? "local");
const scope = rawScope === "global" ? "global" : "local";
const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "ZipCrypto" : "AES";
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
}
function humanizeScope(scope: string): string {
return scope === "global" ? "Global" : "Local";
}
function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "From", value: message.from_header || message.envelope_from || "—" },
{ label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
{ label: "Cc", value: message.cc_header || null },
{ label: "Bcc", value: message.bcc_header || null },
{ label: "Kind", value: message.kind || "—" },
{ label: "Folder", value: message.folder || "—" },
{ label: "Message-ID", value: message.message_id || "—" },
@@ -1297,7 +1585,7 @@ function mockMessageMetaItems(message: MockMailboxMessage) {
];
}
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePreviewAttachment[] {
return (message.attachments ?? []).map((attachment, index) => ({
filename: attachment.filename || `Attachment ${index + 1}`,
contentType: attachment.content_type || undefined,

View File

@@ -1,29 +1,31 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import DismissibleAlert from "../../components/DismissibleAlert";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
type BodyMode = "text" | "html";
type TemplateBodyMode = "text" | "html" | "both";
type BodyEditorMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [bodyMode, setBodyMode] = useState<BodyMode>("text");
const [activeBodyEditor, setActiveBodyEditor] = useState<BodyEditorMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
@@ -51,6 +53,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]);
@@ -73,7 +77,11 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const previewSelection = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? previewEntries[0];
const previewEntry = previewSelection.entry;
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
const templateText = [
getText(template, "subject"),
templateBodyMode !== "html" ? getText(template, "text") : "",
templateBodyMode !== "text" ? getText(template, "html") : ""
].join("\n");
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo(
@@ -90,8 +98,8 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
[attachmentPreviewRules, selectedPreviewEntryIndex]
);
const previewAttachments = useMemo(
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError),
[attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules]
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError, displayDraft),
[attachmentPreviewError, attachmentPreviewLoading, displayDraft, previewAttachmentRules]
);
@@ -99,6 +107,12 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]);
useEffect(() => {
if (templateBodyMode === "text" && activeBodyEditor !== "text") setActiveBodyEditor("text");
if (templateBodyMode === "html" && activeBodyEditor !== "html") setActiveBodyEditor("html");
if (activeEditor !== "subject" && activeEditor !== visibleBodyEditor) setActiveEditor(visibleBodyEditor);
}, [activeBodyEditor, activeEditor, templateBodyMode, visibleBodyEditor]);
useEffect(() => {
if (!previewOpen || !version?.id || !draft) return;
let cancelled = false;
@@ -107,7 +121,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const handle = window.setTimeout(() => {
void previewCampaignAttachments(settings, campaignId, version.id, {
include_unmatched: false,
campaign_json: displayDraft
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
})
.then((response) => {
if (!cancelled) setAttachmentPreviewRules(response.rules);
@@ -133,9 +147,18 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
patch(["template", target], value);
}
function patchTemplateBodyMode(mode: TemplateBodyMode) {
if (locked) return;
patch(["template", "body_mode"], mode);
if (mode !== "both") {
setActiveBodyEditor(mode);
if (activeEditor !== "subject") setActiveEditor(mode);
}
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
const target = activeEditor === "subject" ? "subject" : visibleBodyEditor;
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
const token = `{{${namespace}:${name}}}`;
const currentText = getText(template, target);
@@ -217,30 +240,39 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
onChange={(event) => patchTemplateText("subject", event.target.value)}
/>
</FormField>
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
</div>
{bodyMode === "text" && (
<FormField label="Message body format">
<div className="template-body-mode" role="tablist" aria-label="Message body format">
<button type="button" className={templateBodyMode === "text" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("text")}>Text only</button>
<button type="button" className={templateBodyMode === "html" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("html")}>HTML only</button>
<button type="button" className={templateBodyMode === "both" ? "active" : ""} disabled={locked} onClick={() => patchTemplateBodyMode("both")}>Both</button>
</div>
</FormField>
{templateBodyMode === "both" && (
<div className="template-body-mode template-editor-mode" role="tablist" aria-label="Body editor">
<button type="button" className={visibleBodyEditor === "text" ? "active" : ""} onClick={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}>Plain text</button>
<button type="button" className={visibleBodyEditor === "html" ? "active" : ""} onClick={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}>HTML</button>
</div>
)}
{visibleBodyEditor === "text" && (
<FormField label="Plain text body">
<textarea
ref={textRef}
rows={16}
value={getText(template, "text")}
disabled={locked}
onFocus={() => setActiveEditor("text")}
onFocus={() => { setActiveBodyEditor("text"); setActiveEditor("text"); }}
onChange={(event) => patchTemplateText("text", event.target.value)}
/>
</FormField>
)}
{bodyMode === "html" && (
{visibleBodyEditor === "html" && (
<FormField label="HTML body">
<textarea
ref={htmlRef}
rows={16}
value={getText(template, "html")}
disabled={locked}
onFocus={() => setActiveEditor("html")}
onFocus={() => { setActiveBodyEditor("html"); setActiveEditor("html"); }}
onChange={(event) => patchTemplateText("html", event.target.value)}
/>
</FormField>
@@ -285,19 +317,17 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
</div>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</>
</LoadingFrame>
{previewOpen && (
<MessagePreviewOverlay
<CampaignMessagePreviewOverlay
title="Template preview"
bodyMode={bodyMode}
bodyMode={visibleBodyEditor}
subject={previewSubject}
text={previewText}
html={previewHtml}
text={templateBodyMode === "html" ? null : previewText}
html={templateBodyMode === "text" ? null : previewHtml}
metaItems={templatePreviewMetaItems(previewContext)}
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."}
attachments={previewAttachments}
@@ -328,8 +358,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
function mapResolvedAttachmentsToPreviewBoxes(
rules: CampaignAttachmentPreviewRule[],
loading: boolean,
error: string
): MessagePreviewAttachment[] {
error: string,
draft: Record<string, unknown>
): CampaignMessagePreviewAttachment[] {
if (loading) {
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
}
@@ -337,6 +368,7 @@ function mapResolvedAttachmentsToPreviewBoxes(
return [{ filename: "Attachment preview unavailable", detail: error }];
}
return rules.flatMap((rule) => {
const zipProtection = zipProtectionForRule(rule, draft);
const detailParts = [
rule.source === "global" ? "Global" : "Recipient",
rule.label,
@@ -352,7 +384,9 @@ function mapResolvedAttachmentsToPreviewBoxes(
contentType: match.content_type,
sizeBytes: match.size_bytes,
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
protected: zipProtection.protected,
protectionNote: zipProtection.note
}));
}
return [{
@@ -360,12 +394,46 @@ function mapResolvedAttachmentsToPreviewBoxes(
label: rule.label,
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`,
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null,
protected: zipProtection.protected,
protectionNote: zipProtection.note
}];
});
}
function templatePreviewMetaItems(context: Record<string, string>) {
return [
{ label: "From", value: context["local:from"] || null },
{ label: "To", value: context["local:all_to"] || null },
{ label: "Reply-To", value: context["local:all_reply_to"] || null },
{ label: "Cc", value: context["local:all_cc"] || null },
{ label: "Bcc", value: context["local:all_bcc"] || null }
];
}
function zipProtectionForRule(rule: CampaignAttachmentPreviewRule, draft: Record<string, unknown>): { protected: boolean; note: string | null } {
if (!rule.zip_included) return { protected: false, note: null };
const zipConfig = asRecord(asRecord(draft.attachments).zip);
const archives = asArray(zipConfig.archives).map(asRecord);
const archive = archives.find((item) => {
const id = getText(item, "id");
const name = getText(item, "name", getText(item, "filename_template"));
return (rule.zip_archive_id && id === rule.zip_archive_id) || (rule.zip_filename && name === rule.zip_filename);
}) ?? archives.find((item) => getBool(item, "standard")) ?? archives[0];
if (!archive) return { protected: false, note: null };
const legacyMode = getText(archive, "password_mode");
const protectedArchive = getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode));
if (!protectedArchive) return { protected: false, note: null };
const field = getText(archive, "password_field");
const scope = getText(archive, "password_scope") === "global" ? "global" : "local";
const method = getText(archive, "method", "aes") === "zip_standard" ? "ZipCrypto" : "AES";
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
}
function humanizeScope(scope: string): string {
return scope === "global" ? "Global" : "Local";
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim();
@@ -376,6 +444,11 @@ function recipientLabel(entry: Record<string, unknown>, index: number): string {
return `Recipient ${index + 1}`;
}
function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
if (value === "text" || value === "html" || value === "both") return value;
return "both";
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
}

View File

@@ -1,13 +1,13 @@
import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import { Button } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import ToggleSwitch from "../../../components/ToggleSwitch";
import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor";
import { attachmentRuleZipSelection, createAttachmentRule, nextAttachmentLabel, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "../utils/attachments";
import ManagedFileChooser, { type ManagedAttachmentSelection } from "./ManagedFileChooser";
import { insertAfter, moveArrayItem } from "../../../utils/arrayOrder";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
import { asRecord } from "../utils/campaignView";
export type { AttachmentBasePath, AttachmentRule } from "../utils/attachments";
@@ -22,6 +22,7 @@ type AttachmentRulesOverlayProps = {
emptyText?: string;
basePaths?: AttachmentBasePath[];
zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -33,9 +34,9 @@ type AttachmentRulesTableProps = {
emptyText?: string;
basePaths?: AttachmentBasePath[];
id?: string;
showAddButton?: boolean;
activeChooserRuleIndex?: number | null;
zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -55,6 +56,7 @@ export default function AttachmentRulesOverlay({
emptyText = "No attachment files or matching rules configured yet.",
basePaths = [],
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
@@ -85,7 +87,7 @@ export default function AttachmentRulesOverlay({
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<AttachmentRulesDataGrid
<AttachmentRulesTable
rules={draftRules}
settings={settings}
campaignId={campaignId}
@@ -93,6 +95,7 @@ export default function AttachmentRulesOverlay({
emptyText={emptyText}
basePaths={basePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
activeChooserRuleIndex={null}
onChange={setDraftRules}
/>
@@ -124,26 +127,13 @@ function cloneAttachmentRules(rules: AttachmentRule[]): AttachmentRule[] {
}
export function AttachmentRulesTable({
showAddButton = true,
onChange,
...tableProps
}: AttachmentRulesTableProps) {
function addRule() {
onChange([
...tableProps.rules,
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules), tableProps.basePaths?.[0]?.id ?? "")
]);
}
return (
<div className="attachment-rules-editor">
<div className="attachment-rules-main">
<AttachmentRulesDataGrid {...tableProps} onChange={onChange} />
{showAddButton && (
<div className="button-row compact-actions attachment-rules-footer-actions">
<Button variant="primary" onClick={addRule} disabled={tableProps.disabled || (tableProps.basePaths?.length ?? 0) === 0}>Add file</Button>
</div>
)}
</div>
</div>
);
@@ -159,6 +149,7 @@ export function AttachmentRulesDataGrid({
id = "attachment-rules",
activeChooserRuleIndex = null,
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
@@ -186,6 +177,7 @@ export function AttachmentRulesDataGrid({
}
function openFileChooser(ruleIndex: number) {
if (!filesModuleInstalled) return;
if (onOpenFileChooser) {
onOpenFileChooser(ruleIndex);
return;
@@ -221,14 +213,14 @@ export function AttachmentRulesDataGrid({
<DataGrid
id={id}
rows={rules}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
getRowKey={(rule, index) => String(rule.id ?? index)}
emptyText={basePaths.length === 0 ? "No attachment source is enabled for individual attachments." : emptyText}
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="Add first attachment" />}
className="attachment-rules-table-wrap attachment-rules-table"
rowClassName={(_rule, index) => (activeChooserRuleIndex ?? fileChooser?.ruleIndex) === index ? "is-choosing-file" : undefined}
/>
{fileChooser && (
{fileChooser && filesModuleInstalled && (
<ManagedFileChooser
open
settings={settings}
@@ -251,6 +243,7 @@ type AttachmentRuleColumnContext = {
rules: AttachmentRule[];
basePaths: AttachmentBasePath[];
zipConfig: AttachmentZipCollection;
filesModuleInstalled: boolean;
activeChooserRuleIndex: number | null;
patchRule: (index: number, patch: Partial<AttachmentRule>) => void;
addRule: (afterIndex?: number) => void;
@@ -259,7 +252,7 @@ type AttachmentRuleColumnContext = {
removeRule: (index: number) => void;
};
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: _activeChooserRuleIndex, patchRule, addRule, moveRule, openFileChooser, removeRule }: AttachmentRuleColumnContext): DataGridColumn<AttachmentRule>[] {
return [
{ id: "label", header: "Label", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (rule, index) => <input value={getText(rule, "label")} disabled={disabled} placeholder="Attachment label" onChange={(event) => patchRule(index, { label: event.target.value })} />, value: (rule) => getText(rule, "label") },
{
@@ -308,18 +301,21 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, activeCh
className="chooser-display-input"
value={getText(rule, "file_filter")}
disabled={disabled || basePaths.length === 0}
readOnly
tabIndex={-1}
placeholder="Choose a managed file or pattern"
onClick={() => !disabled && openFileChooser(index)}
readOnly={filesModuleInstalled}
tabIndex={filesModuleInstalled ? -1 : undefined}
placeholder={filesModuleInstalled ? "Choose a managed file or pattern" : "file.pdf or **/*.pdf"}
onChange={(event) => {
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
}}
onClick={() => filesModuleInstalled && !disabled && openFileChooser(index)}
onKeyDown={(event) => {
if (!disabled && (event.key === "Enter" || event.key === " ")) {
if (filesModuleInstalled && !disabled && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
openFileChooser(index);
}
}}
/>
<Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>
{filesModuleInstalled && <Button onClick={() => openFileChooser(index)} disabled={disabled || basePaths.length === 0}>Choose</Button>}
</div>
),
value: (rule) => getText(rule, "file_filter")

View File

@@ -9,14 +9,13 @@ import {
type CampaignShare,
type CampaignShareTargets
} from "../../../api/campaigns";
import Button from "../../../components/Button";
import Card from "../../../components/Card";
import ConfirmDialog from "../../../components/ConfirmDialog";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import Dialog from "../../../components/Dialog";
import FormField from "../../../components/FormField";
import StatusBadge from "../../../components/StatusBadge";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
type TargetType = "user" | "group";
export default function CampaignAccessCard({
@@ -143,12 +142,12 @@ export default function CampaignAccessCard({
<DataGrid id={`campaign-${campaign.id}-shares`} rows={shares} columns={columns} getRowKey={(row) => row.id} emptyText={available === null ? "Loading campaign access…" : "This campaign has no explicit shares."} />
</Card>
<Dialog open={ownerOpen} title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Change campaign owner" onClose={() => !busy && setOwnerOpen(false)} footer={<><Button onClick={() => setOwnerOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>Save owner</Button></>}>
<FormField label="Owner type"><select value={ownerType} onChange={(event) => { const next = event.target.value as TargetType; setOwnerType(next); setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="Owner"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
</Dialog>
<Dialog open={shareOpen} title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
<Dialog open={shareOpen} className="campaign-access-dialog" title="Share campaign" onClose={() => !busy && setShareOpen(false)} footer={<><Button onClick={() => setShareOpen(false)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>Save share</Button></>}>
<FormField label="Target type"><select value={shareType} onChange={(event) => { const next = event.target.value as TargetType; setShareType(next); setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || ""); }}><option value="user">User</option><option value="group">Group</option></select></FormField>
<FormField label="User or group"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">Select</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? ` · ${item.secondary}` : ""}</option>)}</select></FormField>
<FormField label="Access"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">Can view</option><option value="write">Can edit and operate</option></select></FormField>

View File

@@ -1,8 +1,8 @@
import { useState } from "react";
import type { ApiSettings } from "../../../types";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import {
forkCampaignVersion,
lockCampaignVersionPermanently,

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
import type { ApiSettings } from "../../../types";
import {
listFileSpaces,
@@ -12,10 +13,10 @@ import {
type FileSpace,
type ManagedFile
} from "../../../api/files";
import Button from "../../../components/Button";
import ConfirmDialog from "../../../components/ConfirmDialog";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import { Button } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { FolderTree } from "../../files/components/FileManagerComponents";
import { useFileTreeState } from "../../files/hooks/useFileTreeState";
import type { FolderNode, SortColumn, SortDirection } from "../../files/types";
@@ -84,6 +85,8 @@ export default function ManagedFileChooser({
onSelectFolder,
onSelectAttachment
}: ManagedFileChooserProps) {
const filesExplorerUi = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const FolderTreeComponent = filesExplorerUi?.FolderTree ?? FolderTree;
const parsedSource = useMemo(() => parseManagedAttachmentSource(source), [source]);
const normalizedBasePath = normalizeManagedBasePath(basePath);
const storageKey = useMemo(
@@ -345,7 +348,7 @@ export default function ManagedFileChooser({
<span>{space.label}</span>
</button>
{selected && (
<FolderTree
<FolderTreeComponent
nodes={treeNodes}
activeSpaceId={selectedSpaceId}
spaceId={space.id}

View File

@@ -1,22 +1,20 @@
import type { ReactNode } from "react";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import Button from "../../../components/Button";
import { Button, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
export type MessagePreviewAttachment = {
filename?: string | null;
// Campaign review/template/mock previews need recipient navigation, review notes,
// raw MIME inspection and attachment grouping. Generic mailbox reading uses
// core MessageDisplayPanel instead.
export type CampaignMessagePreviewAttachment = MessageDisplayAttachment & {
label?: string | null;
detail?: string | null;
contentType?: string | null;
sizeBytes?: number | null;
archiveGroup?: string | null;
archiveLabel?: string | null;
};
export type MessagePreviewMetaItem = {
export type CampaignMessagePreviewMetaItem = {
label: string;
value: React.ReactNode;
value: ReactNode;
};
export type MessagePreviewNavigation = {
export type CampaignMessagePreviewNavigation = {
index: number;
total: number;
onFirst: () => void;
@@ -25,24 +23,24 @@ export type MessagePreviewNavigation = {
onLast: () => void;
};
export type MessagePreviewOverlayProps = {
export type CampaignMessagePreviewOverlayProps = {
title?: string;
subject?: string | null;
bodyMode?: "text" | "html";
text?: string | null;
html?: string | null;
recipientLabel?: React.ReactNode;
recipientNote?: React.ReactNode;
metaItems?: MessagePreviewMetaItem[];
attachments?: MessagePreviewAttachment[];
recipientLabel?: ReactNode;
recipientNote?: ReactNode;
metaItems?: CampaignMessagePreviewMetaItem[];
attachments?: CampaignMessagePreviewAttachment[];
raw?: string | null;
rawLabel?: string;
navigation?: MessagePreviewNavigation;
navigation?: CampaignMessagePreviewNavigation;
closeLabel?: string;
onClose: () => void;
};
export default function MessagePreviewOverlay({
export default function CampaignMessagePreviewOverlay({
title = "Message preview",
subject,
bodyMode = "text",
@@ -57,10 +55,9 @@ export default function MessagePreviewOverlay({
navigation,
closeLabel = "Close",
onClose
}: MessagePreviewOverlayProps) {
}: CampaignMessagePreviewOverlayProps) {
const shownSubject = subject?.trim() || "No subject";
const shownText = text?.trim() || "No plain-text body to preview.";
const shownHtml = html?.trim() || "<p>No HTML body to preview.</p>";
const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
@@ -88,24 +85,16 @@ export default function MessagePreviewOverlay({
</div>
)}
{metaItems.length > 0 && (
<div className="detail-grid message-preview-meta">
{metaItems.map((item) => (
<div key={item.label}><span className="muted small-note">{item.label}</span><strong>{item.value || "—"}</strong></div>
))}
</div>
)}
<div className="template-preview-box">
<h3>{shownSubject}</h3>
{bodyMode === "html" ? (
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={shownHtml} />
) : (
<pre>{shownText}</pre>
)}
</div>
<MessagePreviewAttachmentBoxes attachments={attachments} />
<MessageDisplayPanel
title={shownSubject}
fields={fields}
bodyText={text}
bodyHtml={html}
preferredBodyMode={bodyMode}
deriveTextFromHtml={false}
attachments={attachments}
emptyText="No message content is available."
/>
{raw && (
<details className="message-preview-raw">
@@ -120,46 +109,8 @@ export default function MessagePreviewOverlay({
);
}
function MessagePreviewAttachmentBoxes({ attachments }: { attachments: MessagePreviewAttachment[] }) {
const direct = attachments.filter((attachment) => !attachment.archiveGroup);
const archiveGroups = new Map<string, MessagePreviewAttachment[]>();
for (const attachment of attachments) {
if (!attachment.archiveGroup) continue;
const group = archiveGroups.get(attachment.archiveGroup) ?? [];
group.push(attachment);
archiveGroups.set(attachment.archiveGroup, group);
}
function attachmentChip(attachment: MessagePreviewAttachment, index: number) {
const filename = attachment.filename?.trim() || attachment.label?.trim() || "Unnamed attachment";
const details = [attachment.detail, attachment.contentType, attachment.sizeBytes ? `${attachment.sizeBytes} bytes` : ""].filter(Boolean).join(" · ");
return (
<div className="attachment-file-chip" key={`${filename}:${index}`}>
<strong>{filename}</strong>
{details && <span>{details}</span>}
</div>
);
}
return (
<div className="template-preview-attachments message-preview-attachments">
<h3>Attachments</h3>
{attachments.length === 0 ? (
<p className="muted small-note">No attachments are effective for this message.</p>
) : (
<div className="message-preview-attachment-layout">
{direct.length > 0 && <div className="attachment-chip-grid">{direct.map(attachmentChip)}</div>}
{[...archiveGroups.entries()].map(([groupName, items]) => (
<section className="attachment-zip-group" key={groupName}>
<header>
<strong>{items[0]?.archiveLabel || groupName}</strong>
<span>{items.length} file{items.length === 1 ? "" : "s"} inside ZIP</span>
</header>
<div className="attachment-chip-grid">{items.map(attachmentChip)}</div>
</section>
))}
</div>
)}
</div>
);
}
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;
export type MessagePreviewNavigation = CampaignMessagePreviewNavigation;
export type MessagePreviewOverlayProps = CampaignMessagePreviewOverlayProps;

View File

@@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import {
buildUndefinedPlaceholders,
extractTemplatePlaceholders,

View File

@@ -1,6 +1,6 @@
import Button from "../../../components/Button";
import Dialog from "../../../components/Dialog";
import DismissibleAlert from "../../../components/DismissibleAlert";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import type { TemplateNamespace, TemplatePlaceholder, UndefinedPlaceholder } from "../utils/templatePlaceholders";
export type TemplateFieldOption = {

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../../types";
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
@@ -119,12 +119,14 @@ export function useCampaignDraftEditor({
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
const unsavedRegistration = useMemo(() => dirty && !locked ? {
title: unsavedTitle,
message: unsavedMessage,
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
} : null, [dirty, locked, saveDraft, unsavedMessage, unsavedTitle]);
useRegisterCampaignUnsavedChanges(unsavedRegistration);
return {
draft,

View File

@@ -2,12 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ApiSettings } from "../../../types";
import {
getCampaign,
getCampaignSummary,
getCampaignVersion,
listCampaignVersions,
type CampaignSummary,
type CampaignVersionDetail
getCampaignWorkspace
} from "../../../api/campaigns";
import type { CampaignWorkspaceData } from "../utils/campaignView";
@@ -45,24 +40,19 @@ export function useCampaignWorkspaceData(
setLoading(true);
setError("");
try {
const needsCampaign = includeCurrentVersion || includeVersions;
const shouldLoadVersions = includeCurrentVersion || includeVersions;
const [campaign, versions] = await Promise.all([
needsCampaign ? getCampaign(settings, campaignId) : Promise.resolve(null),
shouldLoadVersions ? listCampaignVersions(settings, campaignId) : Promise.resolve([]),
]);
const wantedVersionId = selectedVersionId || campaign?.current_version_id || versions[0]?.id;
const [versionResult, summaryResult] = await Promise.allSettled([
includeCurrentVersion && wantedVersionId ? getCampaignVersion(settings, campaignId, wantedVersionId) : Promise.resolve(null),
includeSummary ? getCampaignSummary(settings, campaignId, wantedVersionId) : Promise.resolve(null)
]);
const response = await getCampaignWorkspace(settings, campaignId, {
versionId: selectedVersionId,
includeCurrentVersion,
includeSummary,
includeVersions: shouldLoadVersions,
});
setData({
campaign,
versions,
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
campaign: response.campaign,
versions: response.versions,
currentVersion: response.current_version,
summary: response.summary
});
} catch (err) {
setData(initialData);

View File

@@ -1,7 +1,6 @@
import { asArray, asRecord } from "./campaignView";
import { getBool } from "./draftEditor";
import { addressesFromValue, type MailboxAddress } from "../../../utils/emailAddresses";
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
export type TemplateNamespace = "global" | "local";
export const RECIPIENT_ADDRESS_FIELD_IDS = ["from", "to", "reply_to", "cc", "bcc"] as const;

View File

@@ -0,0 +1,85 @@
const addressArrayKeys = [
"from",
"to",
"cc",
"bcc",
"reply_to",
"bounce_to",
"disposition_notification_to"
];
export function campaignJsonForAttachmentPreview(draft: Record<string, unknown>): Record<string, unknown> {
const next = cloneJson(draft);
const entries = asRecord(next.entries);
if (Array.isArray(entries.inline)) {
entries.inline = entries.inline.map((entry) => normalizePreviewEntry(entry));
}
if (isRecord(entries.defaults)) {
entries.defaults = normalizePreviewEntry(entries.defaults);
}
next.entries = entries;
if (isRecord(next.recipients)) {
next.recipients = normalizeAddressContainer(next.recipients);
}
return next;
}
function normalizePreviewEntry(value: unknown): Record<string, unknown> {
const entry = { ...asRecord(value) };
if (typeof entry.email === "string" && !entry.email.trim()) delete entry.email;
if (typeof entry.name === "string" && !entry.name.trim()) delete entry.name;
for (const key of addressArrayKeys) {
const current = entry[key];
if (Array.isArray(current)) {
const filtered = current.map(normalizeRecipient).filter((item): item is Record<string, unknown> => Boolean(item));
entry[key] = filtered;
continue;
}
if (isRecord(current)) {
const normalized = normalizeRecipient(current);
if (normalized) entry[key] = normalized;
else delete entry[key];
}
}
return entry;
}
function normalizeAddressContainer(value: Record<string, unknown>): Record<string, unknown> {
const container = { ...value };
for (const key of addressArrayKeys) {
const current = container[key];
if (Array.isArray(current)) {
container[key] = current.map(normalizeRecipient).filter((item): item is Record<string, unknown> => Boolean(item));
continue;
}
if (isRecord(current)) {
const normalized = normalizeRecipient(current);
if (normalized) container[key] = normalized;
else delete container[key];
}
}
return container;
}
function normalizeRecipient(value: unknown): Record<string, unknown> | null {
const recipient = { ...asRecord(value) };
const email = typeof recipient.email === "string" ? recipient.email.trim() : "";
if (!email) return null;
recipient.email = email;
if (typeof recipient.name === "string" && !recipient.name.trim()) delete recipient.name;
return recipient;
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? {})) as T;
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View File

@@ -1,11 +1,11 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings, WizardStep } from "../../../types";
import Stepper from "../../../components/Stepper";
import Card from "../../../components/Card";
import Button from "../../../components/Button";
import DismissibleAlert from "../../../components/DismissibleAlert";
import PageTitle from "../../../components/PageTitle";
import { Stepper } from "@govoplan/core-webui";
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 LockedVersionNotice from "../components/LockedVersionNotice";
import { validatePartial } from "../../../api/campaigns";
import { useCampaignWorkspaceData } from "../hooks/useCampaignWorkspaceData";

View File

@@ -1,7 +1,6 @@
import Card from "../../../components/Card";
import MetricCard from "../../../components/MetricCard";
import Button from "../../../components/Button";
import { Card } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
export default function ReviewWizard() {
return (
<div className="content-pad">

View File

@@ -1,6 +1,5 @@
import Card from "../../../components/Card";
import Button from "../../../components/Button";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
export default function SendWizard() {
return (
<div className="content-pad">

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import Button from "../../../../components/Button";
import FormField from "../../../../components/FormField";
import ToggleSwitch from "../../../../components/ToggleSwitch";
import EmailAddressInput from "../../../../components/email/EmailAddressInput";
import MetricCard from "../../../../components/MetricCard";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "../../../../utils/emailAddresses";
import { Button } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { EmailAddressInput } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { addressesFromValue, collectCampaignAddressSuggestions, type MailboxAddress } from "@govoplan/core-webui";
import { asArray, asRecord, stringifyPreview, summaryValue } from "../../utils/campaignView";
import { getBool, getNumber, getText, parseJsonTextarea, stringifyJson } from "../../utils/draftEditor";

View File

@@ -1 +1,79 @@
export { FolderTree } from "@govoplan/files-webui";
import type { DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent } from "react";
import { ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui";
import type { FolderNode } from "../types";
import { normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
type FileActionTarget = { spaceId: string; folderPath: string };
export function FolderTree({
nodes,
activeSpaceId,
spaceId,
currentFolder,
dropTargetKey = "",
expandedKeys,
onOpen,
onToggle,
onContextMenu,
onDragOverTarget,
onDropOnTarget,
onClearDropState,
onRequestDragExpand,
onDragStartFolder,
onDragEndFolder,
dragDropEnabled = true,
contextMenuEnabled = true,
disabled,
depth = 1
}: {
nodes: FolderNode[];
activeSpaceId: string;
spaceId: string;
currentFolder: string;
dropTargetKey?: string;
expandedKeys: Set<string>;
onOpen: (spaceId: string, path: string) => void;
onToggle: (spaceId: string, path: string) => void;
onContextMenu?: (event: ReactMouseEvent<HTMLElement>, spaceId: string, path: string) => void;
onDragOverTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => void;
onDropOnTarget?: (event: ReactDragEvent<HTMLElement>, target: FileActionTarget) => Promise<void>;
onClearDropState?: () => void;
onRequestDragExpand?: (spaceId: string, path: string) => void;
onDragStartFolder?: (spaceId: string, path: string, event: ReactDragEvent<HTMLElement>) => void;
onDragEndFolder?: () => void;
dragDropEnabled?: boolean;
contextMenuEnabled?: boolean;
disabled?: boolean;
depth?: number;
}) {
return (
<ExplorerTree
nodes={nodes}
getNodeId={(node) => treeNodeKey(spaceId, node.path)}
getNodeLabel={(node) => node.name}
getNodeChildren={(node) => node.children}
activeId={activeSpaceId === spaceId ? treeNodeKey(spaceId, currentFolder) : ""}
expandedIds={expandedKeys}
onOpen={(node) => onOpen(spaceId, node.path)}
onToggle={(node) => onToggle(spaceId, node.path)}
disabled={disabled}
depth={depth}
getNodeWrapClassName={(node) => {
const target = { spaceId, folderPath: node.path };
return dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}` ? "is-drop-target" : undefined;
}}
getNodeWrapStyle={(_node, context: ExplorerTreeNodeContext) => ({ paddingLeft: `${Math.min(context.depth * 14, 56)}px` })}
getNodeDraggable={() => dragDropEnabled && !disabled}
onContextMenu={contextMenuEnabled && onContextMenu ? (event, node) => onContextMenu(event, spaceId, node.path) : undefined}
onDragStart={dragDropEnabled && onDragStartFolder ? (event, node) => onDragStartFolder(spaceId, node.path, event) : undefined}
onDragEnd={dragDropEnabled && onDragEndFolder ? () => onDragEndFolder() : undefined}
onDragOver={dragDropEnabled && onDragOverTarget ? (event, node, context) => {
const target = { spaceId, folderPath: node.path };
onDragOverTarget(event, target);
if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path);
} : undefined}
onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined}
onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined}
/>
);
}

View File

@@ -1 +1,87 @@
export { useFileTreeState } from "@govoplan/files-webui";
import { useEffect, useRef, useState } from "react";
import { folderAncestorPaths, isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils";
export function useFileTreeState({
activeSpaceId,
currentFolder,
onOpenFolder
}: {
activeSpaceId: string;
currentFolder: string;
onOpenFolder: (spaceId: string, path: string) => void;
}) {
const [expandedTreeNodes, setExpandedTreeNodes] = useState<Set<string>>(() => new Set());
const dragExpandTimerRef = useRef<number | null>(null);
const dragExpandKeyRef = useRef<string | null>(null);
const suppressTreeAutoExpandKeyRef = useRef<string | null>(null);
useEffect(() => {
if (!activeSpaceId) return;
const ancestors = folderAncestorPaths(currentFolder, { includeSelf: true });
if (ancestors.length === 0) return;
const suppressedKey = suppressTreeAutoExpandKeyRef.current;
suppressTreeAutoExpandKeyRef.current = null;
setExpandedTreeNodes((current) => {
const next = new Set(current);
ancestors.forEach((path) => {
const key = treeNodeKey(activeSpaceId, path);
if (key !== suppressedKey) next.add(key);
});
return next;
});
}, [activeSpaceId, currentFolder]);
function cancelTreeDragExpand() {
if (dragExpandTimerRef.current !== null) {
window.clearTimeout(dragExpandTimerRef.current);
dragExpandTimerRef.current = null;
}
dragExpandKeyRef.current = null;
}
function expandTreeNode(spaceId: string, folderPath: string) {
const key = treeNodeKey(spaceId, folderPath);
setExpandedTreeNodes((current) => {
if (current.has(key)) return current;
const next = new Set(current);
next.add(key);
return next;
});
}
function scheduleTreeDragExpand(spaceId: string, folderPath: string) {
const key = treeNodeKey(spaceId, folderPath);
if (expandedTreeNodes.has(key) || dragExpandKeyRef.current === key) return;
cancelTreeDragExpand();
dragExpandKeyRef.current = key;
dragExpandTimerRef.current = window.setTimeout(() => {
expandTreeNode(spaceId, folderPath);
dragExpandTimerRef.current = null;
dragExpandKeyRef.current = null;
}, 750);
}
function toggleTreeFolder(spaceId: string, folderPath: string) {
const normalizedPath = normalizeFolder(folderPath);
const key = treeNodeKey(spaceId, normalizedPath);
const isExpanded = expandedTreeNodes.has(key);
if (isExpanded && spaceId === activeSpaceId && isPathUnderOrSame(currentFolder, normalizedPath)) {
suppressTreeAutoExpandKeyRef.current = key;
onOpenFolder(spaceId, normalizedPath);
}
setExpandedTreeNodes((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
return {
expandedTreeNodes,
setExpandedTreeNodes,
cancelTreeDragExpand,
scheduleTreeDragExpand,
toggleTreeFolder
};
}

View File

@@ -1 +1,32 @@
export type { FolderNode, SortColumn, SortDirection } from "@govoplan/files-webui";
import type { ManagedFile } from "../../api/files";
export type SortColumn = "name" | "size" | "modified";
export type SortDirection = "asc" | "desc";
export type FolderNode = {
name: string;
path: string;
children: FolderNode[];
fileCount: number;
persisted: boolean;
};
export type FolderEntry = {
kind: "folder";
id: string;
name: string;
path: string;
fileCount: number;
folderCount: number;
totalSize: number;
updatedAt: string;
persisted: boolean;
};
export type FileEntry = {
kind: "file";
id: string;
file: ManagedFile;
};
export type ExplorerEntry = FolderEntry | FileEntry;

View File

@@ -1 +1,214 @@
export { buildExplorerEntries, buildFolderEntryFromSources, buildFolderTree, candidateRenamedPath, directFolderCounts, entryModifiedTime, entryName, entrySelectionKey, entrySize, fileIdsForSelection, folderAncestorPaths, folderBreadcrumbs, folderContentLabel, formatBytes, formatDate, isFileInFolder, isPathUnderOrSame, joinFolder, lastPathSegment, normalizeFilePath, normalizeFolder, parentFolderPath, parseDragState, sortExplorerEntries, sortFolderNodes, treeNodeKey } from "@govoplan/files-webui";
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
import type { FileFolder, ManagedFile } from "../../../api/files";
import type { ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types";
export function buildFolderTree(files: ManagedFile[], folders: FileFolder[]): FolderNode[] {
const byPath = new Map<string, FolderNode>();
const ensureNode = (path: string, persisted: boolean): FolderNode | null => {
const normalized = normalizeFolder(path);
if (!normalized) return null;
const existing = byPath.get(normalized);
if (existing) {
existing.persisted = existing.persisted || persisted;
return existing;
}
const parts = normalized.split("/");
const node: FolderNode = {
name: parts[parts.length - 1],
path: normalized,
children: [],
fileCount: 0,
persisted
};
byPath.set(normalized, node);
const parentPath = parts.slice(0, -1).join("/");
const parent = ensureNode(parentPath, false);
if (parent) parent.children.push(node);
return node;
};
for (const folder of folders) ensureNode(folder.path, true);
for (const file of files) {
const parts = normalizeFilePath(file.display_path).split("/").filter(Boolean);
for (let index = 0; index < parts.length - 1; index += 1) {
const node = ensureNode(parts.slice(0, index + 1).join("/"), false);
if (node) node.fileCount += 1;
}
}
const roots = Array.from(byPath.values()).filter((node) => !node.path.includes("/"));
sortFolderNodes(roots);
return roots;
}
export function sortFolderNodes(nodes: FolderNode[]) {
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const node of nodes) sortFolderNodes(node.children);
}
export function treeNodeKey(spaceId: string, folderPath: string): string {
return `${spaceId}:${normalizeFolder(folderPath)}`;
}
export function folderAncestorPaths(folderPath: string, options: { includeSelf?: boolean } = {}): string[] {
const parts = normalizeFolder(folderPath).split("/").filter(Boolean);
const limit = options.includeSelf ? parts.length : Math.max(parts.length - 1, 0);
const result: string[] = [];
for (let index = 1; index <= limit; index += 1) {
result.push(parts.slice(0, index).join("/"));
}
return result;
}
export function isPathUnderOrSame(path: string, root: string): boolean {
const normalizedPath = normalizeFolder(path);
const normalizedRoot = normalizeFolder(root);
if (!normalizedRoot) return true;
return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
}
export function buildExplorerEntries(files: ManagedFile[], folders: FileFolder[], currentFolder: string, searchActive: boolean): ExplorerEntry[] {
if (searchActive) return files.map((file) => ({ kind: "file", id: file.id, file }));
const folder = normalizeFolder(currentFolder);
const prefix = folder ? `${folder}/` : "";
const folderMap = new Map<string, FolderEntry>();
const directFiles: FileEntry[] = [];
for (const persisted of folders) {
const path = normalizeFolder(persisted.path);
if (!path.startsWith(prefix) || path === folder) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative || relative.includes("/")) continue;
folderMap.set(path, {
kind: "folder",
id: `folder:${path}`,
name: relative,
path,
fileCount: 0,
folderCount: 0,
totalSize: 0,
updatedAt: persisted.updated_at,
persisted: true
});
}
for (const file of files) {
const path = normalizeFilePath(file.display_path || file.filename);
if (prefix && !path.startsWith(prefix)) continue;
const relativePath = prefix ? path.slice(prefix.length) : path;
if (!relativePath) continue;
const slashIndex = relativePath.indexOf("/");
if (slashIndex === -1) {
directFiles.push({ kind: "file", id: file.id, file });
continue;
}
const folderName = relativePath.slice(0, slashIndex);
const folderPath = prefix ? `${folder}/${folderName}` : folderName;
const existing = folderMap.get(folderPath);
if (existing) {
existing.totalSize += file.size_bytes;
if (file.updated_at > existing.updatedAt) existing.updatedAt = file.updated_at;
} else {
folderMap.set(folderPath, {
kind: "folder",
id: `folder:${folderPath}`,
name: folderName,
path: folderPath,
fileCount: 0,
folderCount: 0,
totalSize: file.size_bytes,
updatedAt: file.updated_at,
persisted: false
});
}
}
for (const entry of folderMap.values()) {
const counts = directFolderCounts(files, folders, entry.path);
entry.fileCount = counts.files;
entry.folderCount = counts.folders;
}
return [...Array.from(folderMap.values()), ...directFiles];
}
export function sortExplorerEntries(entries: ExplorerEntry[], column: SortColumn, direction: SortDirection): ExplorerEntry[] {
const factor = direction === "asc" ? 1 : -1;
return [...entries].sort((a, b) => {
if (column === "name") {
if (a.kind !== b.kind) return a.kind === "folder" ? (direction === "asc" ? -1 : 1) : (direction === "asc" ? 1 : -1);
return factor * entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
}
if (column === "size") {
const delta = entrySize(a) - entrySize(b);
if (delta !== 0) return factor * delta;
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
}
const timeA = entryModifiedTime(a);
const timeB = entryModifiedTime(b);
if (timeA !== timeB) return factor * (timeA - timeB);
return entryName(a).localeCompare(entryName(b), undefined, { numeric: true, sensitivity: "base" });
});
}
export function entryName(entry: ExplorerEntry): string {
return entry.kind === "folder" ? entry.name : entry.file.filename;
}
export function entrySize(entry: ExplorerEntry): number {
return entry.kind === "folder" ? entry.totalSize : entry.file.size_bytes;
}
export function entryModifiedTime(entry: ExplorerEntry): number {
const raw = entry.kind === "folder" ? entry.updatedAt : entry.file.updated_at;
const parsed = new Date(raw).getTime();
return Number.isNaN(parsed) ? 0 : parsed;
}
export function directFolderCounts(files: ManagedFile[], folders: FileFolder[], folderPath: string): { files: number; folders: number } {
const normalized = normalizeFolder(folderPath);
const prefix = normalized ? `${normalized}/` : "";
const directFiles = files.filter((file) => parentFolderPath(file.display_path) === normalized).length;
const childFolders = new Set<string>();
for (const folder of folders) {
const path = normalizeFolder(folder.path);
if (!path.startsWith(prefix) || path === normalized) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative) continue;
childFolders.add(relative.split("/")[0]);
}
for (const file of files) {
const path = normalizeFilePath(file.display_path);
if (!path.startsWith(prefix)) continue;
const relative = prefix ? path.slice(prefix.length) : path;
if (!relative || !relative.includes("/")) continue;
childFolders.add(relative.split("/")[0]);
}
return { files: directFiles, folders: childFolders.size };
}
export function parentFolderPath(path: string): string {
const parts = normalizeFilePath(path).split("/").filter(Boolean);
return normalizeFolder(parts.slice(0, -1).join("/"));
}
export function normalizeFolder(value: string): string {
return value.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "").replace(/\/+/g, "/").trim();
}
export function normalizeFilePath(value: string): string {
return value.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+/g, "/").trim();
}
export function formatBytes(value: number): string {
if (value < 1024) return `${value} B`;
const units = ["KB", "MB", "GB", "TB"];
let size = value / 1024;
for (const unit of units) {
if (size < 1024) return `${size.toFixed(size >= 10 ? 0 : 1)} ${unit}`;
size /= 1024;
}
return `${size.toFixed(1)} PB`;
}
export function formatDate(value: string): string {
return formatPlatformDateTime(value);
}

View File

@@ -1,4 +0,0 @@
import { MailProfileScopeManager } from "@govoplan/mail-webui";
export { MailProfilePolicyEditor, MailProfileScopeManager } from "@govoplan/mail-webui";
export default MailProfileScopeManager;

View File

@@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { ExternalLink } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignSummary, listCampaigns, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import Button from "../../components/Button";
import Card from "../../components/Card";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import DismissibleAlert from "../../components/DismissibleAlert";
import LoadingFrame from "../../components/LoadingFrame";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
type OperatorRow = {
@@ -92,11 +94,13 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
{
id: "actions",
header: "Actions",
width: 320,
width: 270,
sticky: "end",
render: (row) => (
<div className="button-row compact-actions">
<Button onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)}>Open queue</Button>
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={`Open queue for ${row.campaign.name}`} title={`Open queue for ${row.campaign.name}`}>
<ExternalLink />
</Button>
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>Retry</Button>
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>Queue unsent</Button>
</div>
@@ -119,15 +123,16 @@ export default function OperatorQueuePage({ settings }: { settings: ApiSettings
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
<div className="dashboard-grid settings-dashboard-grid">
<Card title="Queue pressure"><MetricGrid items={[
["Failed", totals.failed],
["Outcome unknown", totals.outcomeUnknown],
["Unattempted", totals.notAttempted],
["Queued/active", totals.queuedOrActive],
["IMAP failed", totals.imapFailed],
]} /></Card>
</div>
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">Queue pressure</h2>
<QueuePressureGrid items={[
{ label: "Failed", value: totals.failed, tone: "danger" },
{ label: "Outcome unknown", value: totals.outcomeUnknown, tone: "warning" },
{ label: "Unattempted", value: totals.notAttempted, tone: "info" },
{ label: "Queued/active", value: totals.queuedOrActive, tone: "neutral" },
{ label: "IMAP failed", value: totals.imapFailed, tone: "warning" },
]} />
</section>
<LoadingFrame loading={loading} label="Loading operator queue…">
<Card title="Campaign queues">
@@ -164,14 +169,11 @@ function numberValue(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function MetricGrid({ items }: { items: Array<[string, number]> }) {
function QueuePressureGrid({ items }: { items: Array<{ label: string; value: number; tone: "neutral" | "good" | "warning" | "danger" | "info" }> }) {
return (
<div className="status-grid">
{items.map(([label, value]) => (
<div className="status-card" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
<div className="metric-grid queue-pressure-grid">
{items.map((item) => (
<MetricCard key={item.label} label={item.label} value={item.value} tone={item.tone} />
))}
</div>
);

View File

@@ -1,9 +1,10 @@
import { useMemo, useState } from "react";
import Button from "../../components/Button";
import Card from "../../components/Card";
import PageTitle from "../../components/PageTitle";
import FieldLabel from "../../components/help/FieldLabel";
import StatusBadge from "../../components/StatusBadge";
import { ExternalLink } from "lucide-react";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -89,7 +90,18 @@ function templateLibraryColumns(openTemplate: (templateId: string) => void): Dat
{ id: "fields", header: "Fields", width: 240, filterable: true, render: (template) => <div className="chip-row compact-chip-row">{template.fields.slice(0, 3).map((field) => <span key={field} className="field-chip">{field}</span>)}</div>, value: (template) => template.fields.join(", ") },
{ id: "updated", header: "Updated", width: 170, sortable: true, filterable: true, filterType: "date", render: (template) => <span className="muted small-text">{template.updatedAt}</span>, value: (template) => template.updatedAt },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "archived", label: "Archived" }], display: "pill" }, render: (template) => <StatusBadge status={template.status} />, value: (template) => template.status },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (template) => <Button onClick={() => openTemplate(template.id)}>Open</Button> }
{
id: "actions",
header: "Actions",
width: 70,
sticky: "end",
align: "right",
render: (template) => (
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={`Open ${template.name}`} title={`Open ${template.name}`}>
<ExternalLink />
</Button>
)
}
];
}

View File

@@ -18,10 +18,17 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
{
title: "SETTINGS",
items: [
{ id: "mail-settings", label: "Server settings" },
{ id: "mail-settings", label: "Mail settings" },
{ id: "global-settings", label: "Campaign settings" }
]
},
{
title: "POLICIES",
items: [
{ id: "mail-policy", label: "Mail policy" },
{ id: "policies", label: "Campaign policies" }
]
},
{
title: "SEND",
items: [

View File

@@ -1,5 +1,15 @@
/* Campaign workspace data interfaces. Kept separate from layout.css so local sticky/table tweaks stay untouched. */
.workspace-data-page .card { margin-bottom: 18px; }
.workspace-data-page > .workspace-heading {
position: sticky;
top: 0;
z-index: 75;
background: var(--bg, #f8f7f4);
border-bottom: 1px solid var(--line);
box-shadow: 0 8px 18px rgba(65, 60, 52, .08);
margin: -28px -34px 22px;
padding: 18px 34px 16px;
}
.workspace-heading .mono-small { margin-top: 8px; }
.alert.success { background: var(--success-bg); color: var(--success-text); margin-bottom: 12px; }
.alert.info { background: var(--info-bg); color: var(--info-text); margin-bottom: 12px; }
@@ -95,10 +105,6 @@
color: #4d4944;
}
.danger-text { color: var(--danger-text); }
.page-bottom-actions {
justify-content: flex-end;
margin-top: 18px;
}
.section-mini-heading {
margin: 18px 0 8px;
font-size: 12px;
@@ -375,6 +381,28 @@
.settings-dashboard-grid {
align-items: start;
}
.queue-pressure-section {
margin-bottom: 18px;
}
.queue-pressure-heading {
margin: 0 0 12px;
color: var(--text-strong);
font-size: 16px;
}
.queue-pressure-grid {
grid-template-columns: repeat(5, minmax(112px, 1fr));
margin: 0;
}
.queue-pressure-grid .metric-card {
min-width: 0;
}
.queue-pressure-grid .metric-label {
min-height: 2.4em;
}
@media (max-width: 1150px) {
.queue-pressure-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
}
.module-table th,
.module-table td {
white-space: nowrap;
@@ -605,6 +633,13 @@
color: var(--text-strong);
box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.template-body-mode button:disabled {
cursor: not-allowed;
opacity: .58;
}
.template-editor-mode {
margin-top: -2px;
}
.template-editor-actions {
justify-content: flex-end;
}
@@ -812,23 +847,32 @@
margin-top: 12px;
}
.attachment-rules-modal {
width: min(1120px, 100%);
width: min(1500px, 100%);
}
.attachment-rules-body {
min-height: 0;
overflow: auto;
padding: 0;
}
.attachment-rules-body > .data-grid-shell:only-child {
margin: -22px;
width: calc(100% + 44px);
.attachment-rules-body .attachment-rules-editor,
.attachment-rules-body .attachment-rules-main {
width: 100%;
min-width: 0;
}
.attachment-rules-body > .data-grid-shell:only-child,
.attachment-rules-body .attachment-rules-main > .data-grid-shell:only-child {
margin: 0;
width: 100%;
border: 0;
border-radius: 0;
}
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container {
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container,
.attachment-rules-body .attachment-rules-main > .data-grid-shell:only-child .data-grid-container {
height: auto;
min-height: 0;
}
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container .data-grid {
.attachment-rules-body > .data-grid-shell:only-child .data-grid-container .data-grid,
.attachment-rules-body .attachment-rules-main > .data-grid-shell:only-child .data-grid-container .data-grid {
min-height: 0;
}
.attachment-rules-empty {
@@ -854,15 +898,17 @@
.recipient-address-stack { min-width: 260px; }
}
.attachment-rules-table th:nth-child(1),
.attachment-rules-table td:nth-child(1) { min-width: 160px; }
.attachment-rules-table td:nth-child(1) { min-width: 180px; }
.attachment-rules-table th:nth-child(2),
.attachment-rules-table td:nth-child(2) { width: 280px; }
.attachment-rules-table td:nth-child(2) { width: 240px; }
.attachment-rules-table th:nth-child(3),
.attachment-rules-table td:nth-child(3) { min-width: 230px; }
.attachment-rules-table td:nth-child(3) { min-width: 300px; }
.attachment-rules-table th:nth-child(4),
.attachment-rules-table td:nth-child(4) { width: 175px; }
.attachment-rules-table td:nth-child(4) { width: 220px; }
.attachment-rules-table th:nth-child(5),
.attachment-rules-table td:nth-child(5) { width: 160px; }
.attachment-rules-table th:last-child,
.attachment-rules-table td:last-child { width: 145px; }
.attachment-rules-table td:last-child { width: 150px; }
.attachment-rules-table .data-grid-body-cell:last-child .btn {
width: 100%;
}
@@ -883,9 +929,6 @@
.attachment-rules-table tr.is-choosing-file td {
background: var(--panel-soft);
}
.attachment-rules-footer-actions {
margin-top: 12px;
}
.attachment-file-browser-panel {
display: grid;
gap: 12px;
@@ -1192,6 +1235,12 @@
gap: 1rem;
}
.message-preview-modal .message-display-body,
.message-preview-modal .message-display-html-frame {
height: clamp(280px, 45vh, 520px);
max-height: clamp(280px, 45vh, 520px);
}
.message-preview-meta {
margin: 0;
}
@@ -1573,10 +1622,10 @@
.review-flow-navigation {
position: sticky;
top: 0;
top: 85px;
z-index: 35;
margin: 0 0 24px;
padding: 10px;
padding: 15px 10px 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(247, 246, 244, .96);
@@ -2488,6 +2537,20 @@
min-width: 0;
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06));
}
.card-body > .admin-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
}
.card-body > .admin-table-surface:only-child > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.campaigns-page .card-body > .admin-table-surface:only-child {
margin: 0;
width: 100%;
}
.admin-icon-actions {
display: grid;
grid-auto-flow: column;
@@ -2593,6 +2656,16 @@
font-size: 12px;
}
.campaign-access-dialog > .dialog-header,
.campaign-access-dialog > .dialog-footer {
background: var(--surface);
}
.campaign-access-dialog > .dialog-body {
display: grid;
gap: 12px;
}
/* Administration scope separation and ownership safeguards ---------------- */
.admin-overview-section-label {
margin: 2px 0 14px;
@@ -2613,3 +2686,18 @@
font-size: 13px;
line-height: 1.45;
}
/* Campaign policy tables. */
.campaign-policy-row {
grid-template-columns: minmax(190px, .9fr) minmax(180px, .7fr) minmax(220px, 1fr);
}
.campaign-policy-control .toggle-switch {
min-height: 36px;
}
@media (max-width: 900px) {
.campaign-policy-row {
grid-template-columns: 1fr;
align-items: start;
}
}

View File

@@ -1 +0,0 @@
export { insertAfter, moveArrayItem } from "@govoplan/core-webui";

View File

@@ -1,101 +0,0 @@
import { asArray, asRecord } from "../features/campaigns/utils/campaignView";
export type MailboxAddress = {
name?: string;
email: string;
};
export function normalizeEmailAddress(address: MailboxAddress): MailboxAddress {
return {
name: (address.name ?? "").trim(),
email: (address.email ?? "").trim().toLowerCase()
};
}
export function isValidEmailAddress(email: string): boolean {
const normalized = email.trim();
if (!normalized || normalized.length > 254) return false;
return /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(normalized);
}
export function addressFromRecord(value: unknown): MailboxAddress | null {
const record = asRecord(value);
const email = typeof record.email === "string" ? record.email.trim() : "";
if (!email) return null;
const name = typeof record.name === "string" ? record.name.trim() : "";
return normalizeEmailAddress({ name, email });
}
export function addressesFromValue(value: unknown): MailboxAddress[] {
if (Array.isArray(value)) {
return value.map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
}
const single = addressFromRecord(value);
return single ? [single] : [];
}
export function parseMailboxAddressText(input: string): MailboxAddress | null {
const text = input.trim().replace(/[;,]+$/g, "");
if (!text) return null;
const angleMatch = text.match(/^(.+?)\s*<\s*([^<>\s]+@[^<>\s]+)\s*>$/);
if (angleMatch) {
return normalizeEmailAddress({ name: cleanAddressName(angleMatch[1]), email: angleMatch[2] });
}
const emailMatch = text.match(/([^\s<>;,]+@[^\s<>;,]+\.[^\s<>;,]+)/);
if (!emailMatch) return null;
const email = emailMatch[1];
const name = cleanAddressName(`${text.slice(0, emailMatch.index)} ${text.slice((emailMatch.index ?? 0) + email.length)}`);
return normalizeEmailAddress({ name, email });
}
function cleanAddressName(value: string): string {
return value
.trim()
.replace(/[<>]/g, "")
.replace(/^[\s"'`]+|[\s"'`]+$/g, "")
.replace(/[;,]+$/g, "")
.trim();
}
export function collectCampaignAddressSuggestions(draft: Record<string, unknown> | null | undefined): MailboxAddress[] {
if (!draft) return [];
const suggestions: MailboxAddress[] = [];
const recipients = asRecord(draft.recipients);
const sender = addressFromRecord(recipients.from);
if (sender) suggestions.push(sender);
for (const key of ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]) {
suggestions.push(...addressesFromValue(recipients[key]));
}
const entries = asRecord(draft.entries);
for (const entryValue of asArray(entries.inline)) {
const entry = asRecord(entryValue);
const toAddresses = asArray(entry.to).map(addressFromRecord).filter((item): item is MailboxAddress => Boolean(item));
suggestions.push(...toAddresses);
const direct = addressFromRecord(entry.recipient) ?? addressFromRecord(entry);
if (direct) suggestions.push(direct);
}
return dedupeAddresses(suggestions);
}
export function dedupeAddresses(addresses: MailboxAddress[]): MailboxAddress[] {
const seen = new Map<string, MailboxAddress>();
addresses.map(normalizeEmailAddress).forEach((address) => {
if (!address.email) return;
const existing = seen.get(address.email);
if (!existing || (!existing.name && address.name)) {
seen.set(address.email, address);
}
});
return [...seen.values()].sort((left, right) => (left.name || left.email).localeCompare(right.name || right.email));
}
export function addressDisplayName(address: MailboxAddress): string {
const normalized = normalizeEmailAddress(address);
return normalized.name || normalized.email;
}

View File

@@ -1 +0,0 @@
export { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";

View File

@@ -0,0 +1,54 @@
import { campaignJsonForAttachmentPreview } from "../src/features/campaigns/utils/templatePreviewDraft";
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
const draft = {
recipients: {
from: { name: "", email: "" },
to: [{ name: "Bob", email: " bob@example.org " }, { name: "", email: "" }]
},
entries: {
inline: [{
id: "recipient-1",
active: true,
name: "",
email: "",
from: [],
to: [{ name: "", email: "" }, { name: "Alice", email: " alice@example.org " }],
cc: [],
bcc: [],
merge_to: false
}],
defaults: {
name: "",
email: "",
to: [{ name: "", email: "" }],
attachments: []
}
}
};
const normalized = campaignJsonForAttachmentPreview(draft);
const entries = asRecord(normalized.entries);
const inline = entries.inline as Record<string, unknown>[];
const entry = inline[0];
const to = entry.to as Record<string, unknown>[];
const defaults = asRecord(entries.defaults);
const recipients = asRecord(normalized.recipients);
const globalTo = recipients.to as Record<string, unknown>[];
assert(recipients.from === undefined, "empty global from object is stripped");
assert(globalTo.length === 1 && globalTo[0].email === "bob@example.org", "global recipient arrays are sanitized");
assert(entry.email === undefined, "empty legacy entry email is stripped");
assert(entry.name === undefined, "empty legacy entry name is stripped");
assert(Array.isArray(entry.from) && (entry.from as unknown[]).length === 0, "empty address arrays remain arrays");
assert(to.length === 1 && to[0].email === "alice@example.org", "empty recipient objects are removed and valid addresses are trimmed");
assert(defaults.email === undefined, "empty defaults email is stripped");
assert(Array.isArray(defaults.to) && (defaults.to as unknown[]).length === 0, "empty defaults recipients are removed");
assert((asRecord(draft.entries).inline as Record<string, unknown>[])[0].email === "", "original draft is not mutated");

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020",
"DOM"
],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".template-preview-test-build",
"rootDir": "."
},
"include": [
"tests/template-preview-draft.test.ts",
"src/features/campaigns/utils/templatePreviewDraft.ts"
]
}