Release v0.1.2
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
38
AGENTS.md
Normal 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.
|
||||
15
README.md
15
README.md
@@ -17,13 +17,16 @@ Core owns auth, tenants, RBAC evaluation, database/session primitives, CSRF/API
|
||||
|
||||
## Dependencies
|
||||
|
||||
The module has runtime dependencies on:
|
||||
The module has one required runtime dependency:
|
||||
|
||||
- `govoplan-core` for platform services
|
||||
- `govoplan-files` for managed attachment integration
|
||||
- `govoplan-mail` for SMTP/IMAP profile and delivery integration
|
||||
- `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration
|
||||
|
||||
Optional UI behavior can still check installed module availability through core module metadata, but the current backend package declares files and mail as required.
|
||||
Files and mail are optional module integrations declared in the campaign manifest:
|
||||
|
||||
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Without it, campaigns can still use legacy/local attachment paths where configured.
|
||||
- `govoplan-mail` enables reusable mail profiles, delivery policy checks, SMTP sending, and IMAP append behavior. Without it, campaigns can still be authored, validated, built, and reported, but real delivery/profile features are unavailable.
|
||||
|
||||
Backend optional behavior is accessed through core-provided capabilities, not direct required imports. WebUI optional behavior uses core module metadata/capabilities so campaign pages can build and run without files or mail WebUI packages installed.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -60,4 +63,4 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
||||
|
||||
## Release packaging
|
||||
|
||||
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. The campaign WebUI package depends on the tagged files and mail WebUI packages for release builds.
|
||||
The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/campaign-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. Files and mail WebUI packages remain optional product-composition dependencies supplied by the core host build, not required campaign package dependencies.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()}
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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"\}", "}")
|
||||
230
src/govoplan_campaign/backend/integrations.py
Normal file
230
src/govoplan_campaign/backend/integrations.py
Normal 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))
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]] = [
|
||||
{
|
||||
|
||||
36
src/govoplan_campaign/backend/runtime.py
Normal file
36
src/govoplan_campaign/backend/runtime.py
Normal 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))
|
||||
@@ -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
|
||||
|
||||
137
src/govoplan_campaign/backend/schema/campaign.schema.ui.json
Normal file
137
src/govoplan_campaign/backend/schema/campaign.schema.ui.json
Normal 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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 {},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
export default Button;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
export default Card;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
export default ConfirmDialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
export default Dialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
export default DismissibleAlert;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
export default FormField;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
export default LoadingFrame;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { LoadingIndicator } from "@govoplan/core-webui";
|
||||
export default LoadingIndicator;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
export default MetricCard;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
export default PageTitle;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
export default StatusBadge;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { Stepper } from "@govoplan/core-webui";
|
||||
export default Stepper;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
export default ToggleSwitch;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { EmailAddressInput } from "@govoplan/core-webui";
|
||||
export default EmailAddressInput;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
export default FieldLabel;
|
||||
@@ -1,2 +0,0 @@
|
||||
import { InlineHelp } from "@govoplan/core-webui";
|
||||
export default InlineHelp;
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
85
webui/src/features/campaigns/utils/templatePreviewDraft.ts
Normal file
85
webui/src/features/campaigns/utils/templatePreviewDraft.ts
Normal 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);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { MailProfileScopeManager } from "@govoplan/mail-webui";
|
||||
|
||||
export { MailProfilePolicyEditor, MailProfileScopeManager } from "@govoplan/mail-webui";
|
||||
export default MailProfileScopeManager;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { insertAfter, moveArrayItem } from "@govoplan/core-webui";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
54
webui/tests/template-preview-draft.test.ts
Normal file
54
webui/tests/template-preview-draft.test.ts
Normal 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");
|
||||
23
webui/tsconfig.template-preview-tests.json
Normal file
23
webui/tsconfig.template-preview-tests.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user