From 7f25a3ccdf0637e64915d3e2b18bdb7298e6420a Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 24 Jun 2026 01:43:27 +0200 Subject: [PATCH] initial commit after split --- .gitignore | 1 + README.md | 16 + pyproject.toml | 30 + src/govoplan_campaign/__init__.py | 1 + src/govoplan_campaign/backend/__init__.py | 1 + .../backend/attachments/__init__.py | 0 .../backend/attachments/resolver.py | 462 +++ .../backend/campaign/__init__.py | 14 + .../backend/campaign/addressing.py | 153 + .../backend/campaign/entries.py | 222 ++ .../backend/campaign/field_values.py | 62 + .../backend/campaign/loader.py | 79 + .../backend/campaign/models.py | 551 ++++ .../backend/campaign/template_values.py | 25 + .../backend/campaign/validation.py | 437 +++ src/govoplan_campaign/backend/db/__init__.py | 0 src/govoplan_campaign/backend/db/models.py | 334 +++ src/govoplan_campaign/backend/dev/__init__.py | 0 .../backend/dev/mock_campaign.py | 290 ++ .../backend/domain/__init__.py | 0 .../backend/domain/campaign.py | 210 ++ .../backend/domain/fields.py | 126 + src/govoplan_campaign/backend/domain/queue.py | 29 + .../backend/domain/recipients.py | 43 + .../backend/domain/template.py | 28 + src/govoplan_campaign/backend/manifest.py | 166 ++ .../backend/messages/__init__.py | 12 + .../backend/messages/builder.py | 618 ++++ .../backend/messages/models.py | 147 + .../backend/migrations/__init__.py | 0 .../backend/migrations/versions/__init__.py | 0 .../backend/persistence/__init__.py | 0 .../backend/persistence/campaigns.py | 508 ++++ .../backend/persistence/versions.py | 827 ++++++ .../backend/reports/__init__.py | 1 + .../backend/reports/campaigns.py | 429 +++ .../backend/reports/emailing.py | 223 ++ src/govoplan_campaign/backend/router.py | 1953 ++++++++++++ .../backend/schema/campaign.schema.json | 1277 ++++++++ src/govoplan_campaign/backend/schemas.py | 555 ++++ .../backend/sending/__init__.py | 0 .../backend/sending/execution.py | 282 ++ src/govoplan_campaign/backend/sending/jobs.py | 1281 ++++++++ .../backend/services/__init__.py | 0 .../backend/services/attachment_matching.py | 13 + .../backend/services/campaign_executor.py | 135 + .../backend/services/zip_service.py | 65 + src/govoplan_campaign/backend/time.py | 21 + src/govoplan_campaign/py.typed | 0 webui/package.json | 27 + webui/src/api/admin.ts | 519 ++++ webui/src/api/campaigns.ts | 680 +++++ webui/src/api/client.ts | 1 + webui/src/api/files.ts | 1 + webui/src/api/mail.ts | 1 + webui/src/components/Button.tsx | 2 + webui/src/components/Card.tsx | 2 + webui/src/components/ConfirmDialog.tsx | 2 + webui/src/components/Dialog.tsx | 2 + webui/src/components/DismissibleAlert.tsx | 2 + webui/src/components/FormField.tsx | 2 + webui/src/components/LoadingFrame.tsx | 2 + webui/src/components/LoadingIndicator.tsx | 2 + webui/src/components/MetricCard.tsx | 2 + webui/src/components/PageTitle.tsx | 2 + webui/src/components/StatusBadge.tsx | 2 + webui/src/components/Stepper.tsx | 2 + webui/src/components/ToggleSwitch.tsx | 2 + .../components/email/EmailAddressInput.tsx | 2 + webui/src/components/help/FieldLabel.tsx | 2 + webui/src/components/help/InlineHelp.tsx | 2 + webui/src/components/table/DataGrid.tsx | 4 + .../features/addressbook/AddressBookPage.tsx | 97 + .../campaigns/AttachmentsDataPage.tsx | 623 ++++ .../features/campaigns/CampaignAuditPage.tsx | 35 + .../features/campaigns/CampaignFieldsPage.tsx | 308 ++ .../features/campaigns/CampaignJsonView.tsx | 39 + .../features/campaigns/CampaignListPage.tsx | 191 ++ .../campaigns/CampaignOverviewPage.tsx | 281 ++ .../features/campaigns/CampaignReportPage.tsx | 357 +++ .../features/campaigns/CampaignWorkspace.tsx | 137 + .../features/campaigns/GlobalSettingsPage.tsx | 158 + .../features/campaigns/MailSettingsPage.tsx | 627 ++++ .../features/campaigns/RecipientDataPage.tsx | 387 +++ .../campaigns/RecipientDetailsPage.tsx | 203 ++ .../src/features/campaigns/ReviewSendPage.tsx | 1291 ++++++++ .../features/campaigns/TemplateDataPage.tsx | 381 +++ .../components/AttachmentRulesOverlay.tsx | 379 +++ .../components/CampaignAccessCard.tsx | 160 + .../campaigns/components/FieldValueInput.tsx | 36 + .../components/LockedVersionNotice.tsx | 235 ++ .../components/ManagedFileChooser.tsx | 635 ++++ .../components/MessagePreviewOverlay.tsx | 165 ++ .../TemplateExpressionEditorDialog.tsx | 177 ++ .../TemplatePlaceholderControls.tsx | 118 + .../campaigns/components/VersionLine.tsx | 127 + .../context/UnsavedChangesContext.tsx | 178 ++ .../campaigns/hooks/useCampaignDraftEditor.ts | 143 + .../hooks/useCampaignWorkspaceData.ts | 80 + .../features/campaigns/utils/attachments.ts | 273 ++ .../features/campaigns/utils/campaignView.ts | 232 ++ .../features/campaigns/utils/draftEditor.ts | 110 + .../campaigns/utils/fieldDefinitions.ts | 56 + .../campaigns/utils/templatePlaceholders.ts | 256 ++ .../campaigns/wizard/CreateWizard.tsx | 143 + .../campaigns/wizard/ReviewWizard.tsx | 23 + .../features/campaigns/wizard/SendWizard.tsx | 22 + .../wizard/steps/CreateWizardSteps.tsx | 235 ++ .../components/FileManagerComponents.tsx | 1 + .../features/files/hooks/useFileTreeState.ts | 1 + webui/src/features/files/types.ts | 1 + .../features/files/utils/fileManagerUtils.ts | 1 + .../features/mail/MailProfileManagement.tsx | 4 + .../features/operator/OperatorQueuePage.tsx | 178 ++ .../privacy/RetentionPolicyManagement.tsx | 493 ++++ .../src/features/templates/TemplatesPage.tsx | 272 ++ webui/src/index.ts | 10 + webui/src/layout/ModuleSubnav.tsx | 4 + webui/src/layout/SectionSidebar.tsx | 52 + webui/src/module.ts | 25 + webui/src/styles/campaign-workspace.css | 2615 +++++++++++++++++ webui/src/types.ts | 1 + webui/src/utils/arrayOrder.ts | 1 + webui/src/utils/emailAddresses.ts | 101 + webui/src/utils/permissions.ts | 1 + 125 files changed, 25551 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/govoplan_campaign/__init__.py create mode 100644 src/govoplan_campaign/backend/__init__.py create mode 100644 src/govoplan_campaign/backend/attachments/__init__.py create mode 100644 src/govoplan_campaign/backend/attachments/resolver.py create mode 100644 src/govoplan_campaign/backend/campaign/__init__.py create mode 100644 src/govoplan_campaign/backend/campaign/addressing.py create mode 100644 src/govoplan_campaign/backend/campaign/entries.py create mode 100644 src/govoplan_campaign/backend/campaign/field_values.py create mode 100644 src/govoplan_campaign/backend/campaign/loader.py create mode 100644 src/govoplan_campaign/backend/campaign/models.py create mode 100644 src/govoplan_campaign/backend/campaign/template_values.py create mode 100644 src/govoplan_campaign/backend/campaign/validation.py create mode 100644 src/govoplan_campaign/backend/db/__init__.py create mode 100644 src/govoplan_campaign/backend/db/models.py create mode 100644 src/govoplan_campaign/backend/dev/__init__.py create mode 100644 src/govoplan_campaign/backend/dev/mock_campaign.py create mode 100644 src/govoplan_campaign/backend/domain/__init__.py create mode 100644 src/govoplan_campaign/backend/domain/campaign.py create mode 100644 src/govoplan_campaign/backend/domain/fields.py create mode 100644 src/govoplan_campaign/backend/domain/queue.py create mode 100644 src/govoplan_campaign/backend/domain/recipients.py create mode 100644 src/govoplan_campaign/backend/domain/template.py create mode 100644 src/govoplan_campaign/backend/manifest.py create mode 100644 src/govoplan_campaign/backend/messages/__init__.py create mode 100644 src/govoplan_campaign/backend/messages/builder.py create mode 100644 src/govoplan_campaign/backend/messages/models.py create mode 100644 src/govoplan_campaign/backend/migrations/__init__.py create mode 100644 src/govoplan_campaign/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_campaign/backend/persistence/__init__.py create mode 100644 src/govoplan_campaign/backend/persistence/campaigns.py create mode 100644 src/govoplan_campaign/backend/persistence/versions.py create mode 100644 src/govoplan_campaign/backend/reports/__init__.py create mode 100644 src/govoplan_campaign/backend/reports/campaigns.py create mode 100644 src/govoplan_campaign/backend/reports/emailing.py create mode 100644 src/govoplan_campaign/backend/router.py create mode 100644 src/govoplan_campaign/backend/schema/campaign.schema.json create mode 100644 src/govoplan_campaign/backend/schemas.py create mode 100644 src/govoplan_campaign/backend/sending/__init__.py create mode 100644 src/govoplan_campaign/backend/sending/execution.py create mode 100644 src/govoplan_campaign/backend/sending/jobs.py create mode 100644 src/govoplan_campaign/backend/services/__init__.py create mode 100644 src/govoplan_campaign/backend/services/attachment_matching.py create mode 100644 src/govoplan_campaign/backend/services/campaign_executor.py create mode 100644 src/govoplan_campaign/backend/services/zip_service.py create mode 100644 src/govoplan_campaign/backend/time.py create mode 100644 src/govoplan_campaign/py.typed create mode 100644 webui/package.json create mode 100644 webui/src/api/admin.ts create mode 100644 webui/src/api/campaigns.ts create mode 100644 webui/src/api/client.ts create mode 100644 webui/src/api/files.ts create mode 100644 webui/src/api/mail.ts create mode 100644 webui/src/components/Button.tsx create mode 100644 webui/src/components/Card.tsx create mode 100644 webui/src/components/ConfirmDialog.tsx create mode 100644 webui/src/components/Dialog.tsx create mode 100644 webui/src/components/DismissibleAlert.tsx create mode 100644 webui/src/components/FormField.tsx create mode 100644 webui/src/components/LoadingFrame.tsx create mode 100644 webui/src/components/LoadingIndicator.tsx create mode 100644 webui/src/components/MetricCard.tsx create mode 100644 webui/src/components/PageTitle.tsx create mode 100644 webui/src/components/StatusBadge.tsx create mode 100644 webui/src/components/Stepper.tsx create mode 100644 webui/src/components/ToggleSwitch.tsx create mode 100644 webui/src/components/email/EmailAddressInput.tsx create mode 100644 webui/src/components/help/FieldLabel.tsx create mode 100644 webui/src/components/help/InlineHelp.tsx create mode 100644 webui/src/components/table/DataGrid.tsx create mode 100644 webui/src/features/addressbook/AddressBookPage.tsx create mode 100644 webui/src/features/campaigns/AttachmentsDataPage.tsx create mode 100644 webui/src/features/campaigns/CampaignAuditPage.tsx create mode 100644 webui/src/features/campaigns/CampaignFieldsPage.tsx create mode 100644 webui/src/features/campaigns/CampaignJsonView.tsx create mode 100644 webui/src/features/campaigns/CampaignListPage.tsx create mode 100644 webui/src/features/campaigns/CampaignOverviewPage.tsx create mode 100644 webui/src/features/campaigns/CampaignReportPage.tsx create mode 100644 webui/src/features/campaigns/CampaignWorkspace.tsx create mode 100644 webui/src/features/campaigns/GlobalSettingsPage.tsx create mode 100644 webui/src/features/campaigns/MailSettingsPage.tsx create mode 100644 webui/src/features/campaigns/RecipientDataPage.tsx create mode 100644 webui/src/features/campaigns/RecipientDetailsPage.tsx create mode 100644 webui/src/features/campaigns/ReviewSendPage.tsx create mode 100644 webui/src/features/campaigns/TemplateDataPage.tsx create mode 100644 webui/src/features/campaigns/components/AttachmentRulesOverlay.tsx create mode 100644 webui/src/features/campaigns/components/CampaignAccessCard.tsx create mode 100644 webui/src/features/campaigns/components/FieldValueInput.tsx create mode 100644 webui/src/features/campaigns/components/LockedVersionNotice.tsx create mode 100644 webui/src/features/campaigns/components/ManagedFileChooser.tsx create mode 100644 webui/src/features/campaigns/components/MessagePreviewOverlay.tsx create mode 100644 webui/src/features/campaigns/components/TemplateExpressionEditorDialog.tsx create mode 100644 webui/src/features/campaigns/components/TemplatePlaceholderControls.tsx create mode 100644 webui/src/features/campaigns/components/VersionLine.tsx create mode 100644 webui/src/features/campaigns/context/UnsavedChangesContext.tsx create mode 100644 webui/src/features/campaigns/hooks/useCampaignDraftEditor.ts create mode 100644 webui/src/features/campaigns/hooks/useCampaignWorkspaceData.ts create mode 100644 webui/src/features/campaigns/utils/attachments.ts create mode 100644 webui/src/features/campaigns/utils/campaignView.ts create mode 100644 webui/src/features/campaigns/utils/draftEditor.ts create mode 100644 webui/src/features/campaigns/utils/fieldDefinitions.ts create mode 100644 webui/src/features/campaigns/utils/templatePlaceholders.ts create mode 100644 webui/src/features/campaigns/wizard/CreateWizard.tsx create mode 100644 webui/src/features/campaigns/wizard/ReviewWizard.tsx create mode 100644 webui/src/features/campaigns/wizard/SendWizard.tsx create mode 100644 webui/src/features/campaigns/wizard/steps/CreateWizardSteps.tsx create mode 100644 webui/src/features/files/components/FileManagerComponents.tsx create mode 100644 webui/src/features/files/hooks/useFileTreeState.ts create mode 100644 webui/src/features/files/types.ts create mode 100644 webui/src/features/files/utils/fileManagerUtils.ts create mode 100644 webui/src/features/mail/MailProfileManagement.tsx create mode 100644 webui/src/features/operator/OperatorQueuePage.tsx create mode 100644 webui/src/features/privacy/RetentionPolicyManagement.tsx create mode 100644 webui/src/features/templates/TemplatesPage.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/layout/ModuleSubnav.tsx create mode 100644 webui/src/layout/SectionSidebar.tsx create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/campaign-workspace.css create mode 100644 webui/src/types.ts create mode 100644 webui/src/utils/arrayOrder.ts create mode 100644 webui/src/utils/emailAddresses.ts create mode 100644 webui/src/utils/permissions.ts diff --git a/.gitignore b/.gitignore index 3c46444..95f869e 100644 --- a/.gitignore +++ b/.gitignore @@ -326,3 +326,4 @@ cython_debug/ # Built Visual Studio Code Extensions *.vsix +**/runtime/ \ No newline at end of file diff --git a/README.md b/README.md index 2d274e1..8ccb1b5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,18 @@ # govoplan-campaign +GovOPlaN Campaign module. + +This repository owns the Campaign module manifest, backend router, campaign schemas, campaign ORM models, campaign JSON validation, persistence, reports, message building, mock-send, queue/send control services, and campaign/operator/template/address-book WebUI package. The module currently has hard runtime dependencies on `govoplan-files` and `govoplan-mail` because managed attachments and SMTP/IMAP execution are imported directly. + +The remaining `app.*` imports are transitional core adapters for auth, audit, Celery dispatch, and legacy access models. + +## Development + +Install through the core development environment: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -r requirements-dev.txt +``` + +The backend module is registered through the `govoplan.modules` entry point `campaigns`. The frontend package is `@govoplan/campaign-webui`. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fc64ed8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-campaign" +version = "0.1.0" +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.0", + "govoplan-files>=0.1.0", + "govoplan-mail>=0.1.0", + "jsonschema>=4,<5", + "pydantic>=2,<3", + "SQLAlchemy>=2,<3", + "pyzipper>=0.3,<1", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_campaign = ["py.typed", "backend/schema/*.json"] + +[project.entry-points."govoplan.modules"] +campaigns = "govoplan_campaign.backend.manifest:get_manifest" diff --git a/src/govoplan_campaign/__init__.py b/src/govoplan_campaign/__init__.py new file mode 100644 index 0000000..cf26b8a --- /dev/null +++ b/src/govoplan_campaign/__init__.py @@ -0,0 +1 @@ +"""govoplan-campaign module package.""" diff --git a/src/govoplan_campaign/backend/__init__.py b/src/govoplan_campaign/backend/__init__.py new file mode 100644 index 0000000..5ab5106 --- /dev/null +++ b/src/govoplan_campaign/backend/__init__.py @@ -0,0 +1 @@ +"""Backend integration for this GovOPlaN module.""" diff --git a/src/govoplan_campaign/backend/attachments/__init__.py b/src/govoplan_campaign/backend/attachments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/attachments/resolver.py b/src/govoplan_campaign/backend/attachments/resolver.py new file mode 100644 index 0000000..9ce8c82 --- /dev/null +++ b/src/govoplan_campaign/backend/attachments/resolver.py @@ -0,0 +1,462 @@ +from __future__ import annotations + +import fnmatch +import re +from enum import StrEnum +from pathlib import Path +from typing import Any, Iterable + +from pydantic import BaseModel, ConfigDict, Field + +from govoplan_campaign.backend.campaign.entries import load_campaign_entries +from govoplan_campaign.backend.campaign.template_values import build_template_values +from govoplan_campaign.backend.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig, ZipArchiveConfig, ZipRuleMode + + +class AttachmentScope(StrEnum): + GLOBAL = "global" + ENTRY = "entry" + + +class AttachmentMatchStatus(StrEnum): + OK = "ok" + MISSING = "missing" + AMBIGUOUS = "ambiguous" + + +class MessageAttachmentStatus(StrEnum): + READY = "ready" + WARNING = "warning" + NEEDS_REVIEW = "needs_review" + BLOCKED = "blocked" + EXCLUDED = "excluded" + INACTIVE = "inactive" + + +class ResolutionSeverity(StrEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class AttachmentIssue(BaseModel): + model_config = ConfigDict(extra="forbid") + + severity: ResolutionSeverity + code: str + message: str + behavior: Behavior | None = None + + +class ResolvedAttachment(BaseModel): + model_config = ConfigDict(extra="forbid") + + scope: AttachmentScope + index: int + attachment_id: str | None = None + label: str | None = None + base_dir_template: str + file_filter_template: str + base_path_name: str | None = None + base_path: str | None = None + base_dir: str + file_filter: str + directory: str + include_subdirs: bool + required: bool + allow_multiple: bool + zip_enabled: bool + zip_mode: ZipRuleMode = ZipRuleMode.INHERIT + zip_archive_id: str | None = None + zip_filename: str | None = None + status: AttachmentMatchStatus + behavior: Behavior | None = None + matches: list[str] = Field(default_factory=list) + issues: list[AttachmentIssue] = Field(default_factory=list) + + +class EntryAttachmentResolution(BaseModel): + model_config = ConfigDict(extra="forbid") + + entry_index: int + entry_id: str | None = None + active: bool + status: MessageAttachmentStatus + attachments: list[ResolvedAttachment] = Field(default_factory=list) + issues: list[AttachmentIssue] = Field(default_factory=list) + + @property + def match_count(self) -> int: + return sum(len(item.matches) for item in self.attachments) + + +class AttachmentResolutionReport(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_id: str + campaign_name: str + campaign_file: str + attachments_base_path: str + entries_count: int + entries: list[EntryAttachmentResolution] = Field(default_factory=list) + + @property + def ready_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.READY) + + @property + def warning_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.WARNING) + + @property + def needs_review_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.NEEDS_REVIEW) + + @property + def blocked_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.BLOCKED) + + @property + def excluded_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.EXCLUDED) + + @property + def inactive_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.INACTIVE) + + +def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path: + campaign_path = Path(campaign_file).resolve() + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_path.parent / path).resolve() + + +_DOLLAR_FIELD_PATTERN = re.compile(r"(? str: + key = raw.strip() + if key.startswith("fields."): + key = key.removeprefix("fields.") + elif key.startswith("local."): + key = "local::" + key.removeprefix("local.") + elif key.startswith("global."): + key = "global::" + key.removeprefix("global.") + + if key.startswith("local::") or key.startswith("global::"): + return key + if key.startswith("local:"): + return "local::" + key.removeprefix("local:") + if key.startswith("global:"): + return "global::" + key.removeprefix("global:") + return key + + +def _render_template(template: str, values: dict[str, Any]) -> str: + def replace(match: re.Match[str]) -> str: + key = _normalize_template_key(match.group(1)) + if key in values: + value = values[key] + return "" if value is None else str(value) + return match.group(0) + + rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template) + rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered) + return rendered.replace(r"\${", "${").replace(r"\}", "}") + + +def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str: + rendered = _render_template(config.base_dir, values).strip() + return rendered or "." + + +def _base_path_by_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None: + for base_path in config.attachments.base_paths: + if base_path.path == rendered_base_dir: + return base_path + return None + + +def _base_path_by_id(config: CampaignConfig, base_path_id: str | None) -> AttachmentBasePathConfig | None: + if not base_path_id: + return None + return next((base_path for base_path in config.attachments.base_paths if base_path.id == base_path_id), None) + + +def _default_base_path(config: CampaignConfig) -> AttachmentBasePathConfig: + return config.attachments.base_paths[0] + + +def _selected_base_path( + config: CampaignConfig, + attachment_config: AttachmentConfig, + rendered_base_dir: str, +) -> AttachmentBasePathConfig | None: + if not config.attachments.base_paths: + return None + selected_by_id = _base_path_by_id(config, attachment_config.base_path_id) + if selected_by_id is not None: + return selected_by_id + if rendered_base_dir in {"", "."}: + return _default_base_path(config) + return _base_path_by_path(config, rendered_base_dir) + + +def _rule_allows_multiple(config: AttachmentConfig, rendered_file_filter: str) -> bool: + """Return whether a rule may produce multiple attachments. + + New UI versions no longer expose allow_multiple. Treat wildcard patterns as + inherently multi-match-capable while keeping the legacy allow_multiple flag + for old campaign JSON. + """ + + return config.allow_multiple or any(char in rendered_file_filter for char in "*?[") + + +def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior: + if config.missing_behavior is not None: + return config.missing_behavior + if config.required: + return campaign_config.validation_policy.missing_required_attachment + return campaign_config.validation_policy.missing_optional_attachment + + +def _ambiguous_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior: + return config.ambiguous_behavior or campaign_config.validation_policy.ambiguous_attachment_match + + +def _attachment_zip_archive( + campaign_config: CampaignConfig, + attachment_config: AttachmentConfig, +) -> ZipArchiveConfig | None: + collection = campaign_config.attachments.zip + if not collection.enabled: + return None + + selection = (attachment_config.zip.archive_id or ZipRuleMode.INHERIT.value).strip() + if selection == ZipRuleMode.EXCLUDE.value or attachment_config.zip.mode == ZipRuleMode.EXCLUDE: + return None + if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value}: + selected = collection.archive_by_id(selection) + if selected is not None: + return selected + + # Legacy rule-level include/enabled values map to the campaign standard. + if attachment_config.zip.mode == ZipRuleMode.INCLUDE or attachment_config.zip.enabled: + return collection.standard_archive + return collection.standard_archive + + +def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None: + if archive is None: + return None + rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip" + return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip" + + +def _entry_attachment_allowed(config: CampaignConfig, attachment_config: AttachmentConfig, values: dict[str, Any]) -> bool: + rendered_base_dir = _rendered_base_dir(attachment_config, values) + selected = _selected_base_path(config, attachment_config, rendered_base_dir) + if config.attachments.base_paths: + return bool(selected and selected.allow_individual) + return config.attachments.allow_individual + + +def _iter_effective_attachment_configs( + config: CampaignConfig, + entry: EntryConfig, + values: dict[str, Any], +) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]: + if entry.combine_attachments: + for index, attachment_config in enumerate(config.attachments.global_): + yield AttachmentScope.GLOBAL, index, attachment_config + for index, attachment_config in enumerate(entry.attachments): + if _entry_attachment_allowed(config, attachment_config, values): + yield AttachmentScope.ENTRY, index, attachment_config + + +def _resolve_attachment_directory( + *, + campaign_file: str | Path, + campaign_config: CampaignConfig, + attachment_config: AttachmentConfig, + rendered_base_dir: str, +) -> tuple[Path, AttachmentBasePathConfig | None]: + """Resolve the directory for an attachment rule. + + Legacy campaigns used attachments.base_path as the root and base_dir as a + child directory. Current WebUI campaigns select one named base path directly + in base_dir. Prefer the new base_paths list when present to avoid resolving + e.g. attachments/base_path + base_dir twice. + """ + + selected_base_path = _selected_base_path(campaign_config, attachment_config, rendered_base_dir) + if selected_base_path is not None: + return _resolve_path(campaign_file, selected_base_path.path), selected_base_path + + if campaign_config.attachments.base_paths: + return _resolve_path(campaign_file, rendered_base_dir), None + + legacy_root = _resolve_path(campaign_file, campaign_config.attachments.base_path) + return (legacy_root / rendered_base_dir).resolve(), None + + +def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]: + if not directory.exists() or not directory.is_dir(): + return [] + if include_subdirs: + # pathlib.rglob accepts glob patterns, but fnmatch keeps behavior predictable + # when file_filter is supplied as the Java-style filter portion only. + return sorted(path for path in directory.rglob("*") if path.is_file() and fnmatch.fnmatch(path.name, file_filter)) + return sorted(path for path in directory.glob(file_filter) if path.is_file()) + + +def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue: + code = "missing_required_attachment" if config.required else "missing_optional_attachment" + severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING + return AttachmentIssue( + severity=severity, + code=code, + message=f"No file matched attachment filter {config.file_filter!r}", + behavior=behavior, + ) + + +def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_count: int) -> AttachmentIssue: + severity = ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING + return AttachmentIssue( + severity=severity, + code="ambiguous_attachment_match", + message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but it is configured as a direct/single-file selection", + behavior=behavior, + ) + + +def _resolve_one_config( + *, + campaign_file: str | Path, + campaign_config: CampaignConfig, + values: dict[str, Any], + scope: AttachmentScope, + index: int, + config: AttachmentConfig, +) -> ResolvedAttachment: + rendered_base_dir = _rendered_base_dir(config, values) + rendered_file_filter = _render_template(config.file_filter, values) + directory, selected_base_path = _resolve_attachment_directory( + campaign_file=campaign_file, + campaign_config=campaign_config, + attachment_config=config, + rendered_base_dir=rendered_base_dir, + ) + matches = _match_files(directory, rendered_file_filter, config.include_subdirs) + allow_multiple = _rule_allows_multiple(config, rendered_file_filter) + + issues: list[AttachmentIssue] = [] + behavior: Behavior | None = None + + if not matches: + status = AttachmentMatchStatus.MISSING + behavior = _missing_behavior(campaign_config, config) + issues.append(_issue_for_missing(config, behavior)) + elif len(matches) > 1 and not allow_multiple: + status = AttachmentMatchStatus.AMBIGUOUS + behavior = _ambiguous_behavior(campaign_config, config) + issues.append(_issue_for_ambiguous(config, behavior, len(matches))) + else: + status = AttachmentMatchStatus.OK + + archive = _attachment_zip_archive(campaign_config, config) + return ResolvedAttachment( + scope=scope, + index=index, + attachment_id=config.id, + label=config.label, + base_dir_template=config.base_dir, + file_filter_template=config.file_filter, + base_path_name=selected_base_path.name if selected_base_path else None, + base_path=selected_base_path.path if selected_base_path else None, + base_dir=rendered_base_dir, + file_filter=rendered_file_filter, + directory=str(directory), + include_subdirs=config.include_subdirs, + required=config.required, + allow_multiple=allow_multiple, + zip_enabled=archive is not None, + zip_mode=config.zip.mode, + zip_archive_id=archive.id if archive else None, + zip_filename=_render_zip_filename(archive, values), + status=status, + behavior=behavior, + matches=[str(path) for path in matches], + issues=issues, + ) + + +def _status_from_issues(active: bool, issues: list[AttachmentIssue]) -> MessageAttachmentStatus: + if not active: + return MessageAttachmentStatus.INACTIVE + behaviors = {issue.behavior for issue in issues if issue.behavior is not None} + if Behavior.BLOCK in behaviors: + return MessageAttachmentStatus.BLOCKED + if Behavior.DROP in behaviors: + return MessageAttachmentStatus.EXCLUDED + if Behavior.ASK in behaviors: + return MessageAttachmentStatus.NEEDS_REVIEW + if Behavior.WARN in behaviors: + return MessageAttachmentStatus.WARNING + return MessageAttachmentStatus.READY + + +def resolve_entry_attachments( + *, + config: CampaignConfig, + campaign_file: str | Path, + entry: EntryConfig, + entry_index: int, +) -> EntryAttachmentResolution: + values = build_template_values(config, entry) + resolved: list[ResolvedAttachment] = [] + + if entry.active: + for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry, values): + resolved.append( + _resolve_one_config( + campaign_file=campaign_file, + campaign_config=config, + values=values, + scope=scope, + index=index, + config=attachment_config, + ) + ) + + issues = [issue for item in resolved for issue in item.issues] + return EntryAttachmentResolution( + entry_index=entry_index, + entry_id=entry.id, + active=entry.active, + status=_status_from_issues(entry.active, issues), + attachments=resolved, + issues=issues, + ) + + +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) + resolved_entries = [ + resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index) + for index, entry in enumerate(entries, start=1) + ] + return AttachmentResolutionReport( + campaign_id=config.campaign.id, + campaign_name=config.campaign.name, + campaign_file=str(Path(campaign_file).resolve()), + attachments_base_path=str(base_path), + entries_count=len(entries), + entries=resolved_entries, + ) diff --git a/src/govoplan_campaign/backend/campaign/__init__.py b/src/govoplan_campaign/backend/campaign/__init__.py new file mode 100644 index 0000000..96c2d09 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/__init__.py @@ -0,0 +1,14 @@ +"""Campaign JSON model, loading and validation helpers.""" + +from .models import CampaignConfig +from .loader import load_campaign_config, load_campaign_json +from .validation import validate_campaign_config, SemanticIssue, SemanticReport + +__all__ = [ + "CampaignConfig", + "load_campaign_config", + "load_campaign_json", + "validate_campaign_config", + "SemanticIssue", + "SemanticReport", +] diff --git a/src/govoplan_campaign/backend/campaign/addressing.py b/src/govoplan_campaign/backend/campaign/addressing.py new file mode 100644 index 0000000..49c5d93 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/addressing.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from email.utils import formataddr +from typing import Iterable + +from .models import CampaignConfig, EntryConfig, RecipientConfig + +ADDRESS_TEMPLATE_FIELDS = ("from", "to", "reply_to", "cc", "bcc") + + +def _deduplicate(recipients: Iterable[RecipientConfig]) -> list[RecipientConfig]: + unique: list[RecipientConfig] = [] + seen: set[str] = set() + for recipient in recipients: + key = recipient.email.strip().casefold() + if not key or key in seen: + continue + seen.add(key) + unique.append(recipient) + return unique + + +def _effective_list( + *, + global_recipients: list[RecipientConfig], + local_recipients: list[RecipientConfig], + allow_individual: bool, + merge: bool, +) -> list[RecipientConfig]: + if not allow_individual: + return _deduplicate(global_recipients) + if not local_recipients: + return _deduplicate(global_recipients) + if merge: + return _deduplicate([*global_recipients, *local_recipients]) + return _deduplicate(local_recipients) + + + +def _effective_single( + *, + global_recipients: list[RecipientConfig], + local_recipients: list[RecipientConfig], + allow_individual: bool, +) -> list[RecipientConfig]: + """Resolve a single-mailbox header such as From. + + A recipient-specific value overrides the campaign value when allowed. Any + additional legacy values are ignored deliberately; the canonical schema and + WebUI prevent creating them. + """ + + candidates = local_recipients if allow_individual and local_recipients else global_recipients + unique = _deduplicate(candidates) + return unique[:1] + +def effective_address_lists(config: CampaignConfig, entry: EntryConfig) -> dict[str, list[RecipientConfig]]: + """Return the exact address lists used by a built message. + + ``merge_*`` is the canonical configuration. Older ``combine_*`` values are + normalized by EntryConfig before this function is called. + """ + + return { + "from": _effective_single( + global_recipients=config.recipients.from_, + local_recipients=entry.from_, + allow_individual=config.recipients.allow_individual_from, + ), + "to": _effective_list( + global_recipients=config.recipients.to, + local_recipients=entry.to, + allow_individual=config.recipients.allow_individual_to, + merge=entry.merge_to, + ), + "cc": _effective_list( + global_recipients=config.recipients.cc, + local_recipients=entry.cc, + allow_individual=config.recipients.allow_individual_cc, + merge=entry.merge_cc, + ), + "bcc": _effective_list( + global_recipients=config.recipients.bcc, + local_recipients=entry.bcc, + allow_individual=config.recipients.allow_individual_bcc, + merge=entry.merge_bcc, + ), + "reply_to": _effective_list( + global_recipients=config.recipients.reply_to, + local_recipients=entry.reply_to, + allow_individual=config.recipients.allow_individual_reply_to, + merge=entry.merge_reply_to, + ), + "bounce_to": _effective_list( + global_recipients=config.recipients.bounce_to, + local_recipients=entry.bounce_to, + allow_individual=config.recipients.allow_individual_bounce_to, + merge=entry.merge_bounce_to, + ), + "disposition_notification_to": _effective_list( + global_recipients=config.recipients.disposition_notification_to, + local_recipients=entry.disposition_notification_to, + allow_individual=config.recipients.allow_individual_disposition_notification_to, + merge=entry.merge_disposition_notification_to, + ), + } + + +def formatted_recipient(recipient: RecipientConfig) -> str: + return formataddr((recipient.name or "", recipient.email)) + + +def recipient_template_values(addresses: dict[str, list[RecipientConfig]]) -> dict[str, str]: + """Expose effective address lists as local template values. + + Common forms: + local:to first formatted address + local:all_to all formatted addresses, comma separated + local:to.email first address only + local:all_to.email all email addresses, comma separated + local:to[2] second formatted address (one-based) + local:to[2].email second email address + + Legacy zero-based keys such as ``local:to.0.email`` remain available. + """ + + values: dict[str, str] = {} + for field_name in ADDRESS_TEMPLATE_FIELDS: + recipients = addresses.get(field_name, []) + formatted = [formatted_recipient(recipient) for recipient in recipients] + emails = [recipient.email for recipient in recipients] + names = [recipient.name or "" for recipient in recipients] + + first_formatted = formatted[0] if formatted else "" + first_email = emails[0] if emails else "" + first_name = names[0] if names else "" + values[f"local::{field_name}"] = first_formatted + values[f"local::{field_name}.email"] = first_email + values[f"local::{field_name}.name"] = first_name + values[f"local::all_{field_name}"] = ", ".join(formatted) + values[f"local::all_{field_name}.email"] = ", ".join(emails) + values[f"local::all_{field_name}.name"] = ", ".join(name for name in names if name) + + for index, recipient in enumerate(recipients): + one_based = index + 1 + values[f"local::{field_name}[{one_based}]"] = formatted[index] + values[f"local::{field_name}[{one_based}].email"] = recipient.email + values[f"local::{field_name}[{one_based}].name"] = recipient.name or "" + # Preserve the original zero-based recipient field syntax. + values[f"local::{field_name}.{index}.email"] = recipient.email + values[f"local::{field_name}.{index}.name"] = recipient.name or "" + values[f"local::{field_name}.{index}.type"] = recipient.recipient_type.value + return values diff --git a/src/govoplan_campaign/backend/campaign/entries.py b/src/govoplan_campaign/backend/campaign/entries.py new file mode 100644 index 0000000..23a0e94 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/entries.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import copy +import csv +import json +from pathlib import Path +from typing import Any + +from .models import CampaignConfig, EntryConfig, SourceType + + +class EntryLoadError(ValueError): + """Raised when campaign entries cannot be loaded from inline or external sources.""" + + +def _resolve(campaign_file: str | Path, raw_path: str) -> Path: + campaign_path = Path(campaign_file).resolve() + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_path.parent / path).resolve() + + +def _parse_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"}: + return True + if text in {"0", "false", "no", "n", "nein", "", "inactive", "inaktiv"}: + return False + raise EntryLoadError(f"cannot parse boolean value: {value!r}") + + +def _parse_scalar_for_target(target: str, value: Any) -> Any: + bool_targets = { + "active", + "merge_from", + "merge_to", + "merge_cc", + "merge_bcc", + "merge_reply_to", + "merge_bounce_to", + "merge_disposition_notification_to", + "combine_to", + "combine_cc", + "combine_bcc", + "combine_reply_to", + "combine_bounce_to", + "combine_disposition_notification_to", + "combine_attachments", + } + if target in bool_targets: + return _parse_bool(value) + if target.endswith(".include_subdirs") or target.endswith(".required") or target.endswith(".allow_multiple"): + return _parse_bool(value) + if target.endswith(".zip.enabled"): + return _parse_bool(value) + return value + + +def _ensure_list_length(values: list[Any], index: int, factory: Any) -> None: + while len(values) <= index: + values.append(factory()) + + +def _set_recipient_value(entry_data: dict[str, Any], target: str, value: Any) -> bool: + # Examples: from.email, to.0.email, cc.0.name + if target.startswith("from."): + entry_data.setdefault("from", {}) + _, field = target.split(".", 1) + if field == "type": + field = "type" + entry_data["from"][field] = value + return True + + for recipient_list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]: + prefix = recipient_list_name + "." + if not target.startswith(prefix): + continue + parts = target.split(".") + if len(parts) != 3 or not parts[1].isdigit(): + raise EntryLoadError(f"invalid recipient mapping target: {target}") + index = int(parts[1]) + field = parts[2] + recipients = entry_data.setdefault(recipient_list_name, []) + _ensure_list_length(recipients, index, dict) + recipients[index][field] = value + return True + return False + + +def _set_attachment_value(entry_data: dict[str, Any], target: str, value: Any) -> bool: + if not target.startswith("attachments."): + return False + parts = target.split(".") + if len(parts) < 3 or not parts[1].isdigit(): + raise EntryLoadError(f"invalid attachment mapping target: {target}") + + index = int(parts[1]) + attachments = entry_data.setdefault("attachments", []) + _ensure_list_length(attachments, index, dict) + attachment = attachments[index] + + if parts[2] == "zip": + if len(parts) != 4: + raise EntryLoadError(f"invalid zip attachment mapping target: {target}") + attachment.setdefault("zip", {})[parts[3]] = value + return True + + if len(parts) != 3: + raise EntryLoadError(f"invalid attachment mapping target: {target}") + attachment[parts[2]] = value + return True + + +def _set_entry_value(entry_data: dict[str, Any], target: str, value: Any) -> None: + value = _parse_scalar_for_target(target, value) + if value is None: + return + if isinstance(value, str) and value == "": + return + + if target.startswith("fields."): + _, field_name = target.split(".", 1) + entry_data.setdefault("fields", {})[field_name] = value + return + + if _set_recipient_value(entry_data, target, value): + return + if _set_attachment_value(entry_data, target, value): + return + + entry_data[target] = value + + +def _entry_defaults_data(config: CampaignConfig) -> dict[str, Any]: + if config.entries.defaults is None: + return {} + return config.entries.defaults.model_dump(mode="json", by_alias=True, exclude_none=True) + + +def _load_csv_rows(path: Path, *, delimiter: str, encoding: str) -> list[dict[str, Any]]: + try: + with path.open("r", encoding=encoding, newline="") as handle: + reader = csv.DictReader(handle, delimiter=delimiter) + return [dict(row) for row in reader] + except OSError as exc: + raise EntryLoadError(f"could not read CSV entries source {path}: {exc}") from exc + + +def _load_json_rows(path: Path, *, encoding: str) -> list[dict[str, Any]]: + try: + with path.open("r", encoding=encoding) as handle: + data = json.load(handle) + except OSError as exc: + raise EntryLoadError(f"could not read JSON entries source {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise EntryLoadError(f"invalid JSON entries source {path}: {exc}") from exc + + if isinstance(data, list): + rows = data + elif isinstance(data, dict) and isinstance(data.get("entries"), list): + rows = data["entries"] + else: + raise EntryLoadError("JSON entries source must be a list or an object with an 'entries' list") + + if not all(isinstance(row, dict) for row in rows): + raise EntryLoadError("JSON entries source rows must be objects") + return [dict(row) for row in rows] + + +def _row_to_entry(defaults_data: dict[str, Any], mapping: dict[str, str], row: dict[str, Any], row_number: int) -> EntryConfig: + entry_data = copy.deepcopy(defaults_data) + for target, source_name in mapping.items(): + if source_name not in row: + # Detailed missing-column validation is handled in semantic validation. + continue + try: + _set_entry_value(entry_data, target, row[source_name]) + except EntryLoadError: + raise + except Exception as exc: + raise EntryLoadError(f"row {row_number}: could not map {source_name!r} to {target!r}: {exc}") from exc + try: + return EntryConfig.model_validate(entry_data) + except Exception as exc: + raise EntryLoadError(f"row {row_number}: mapped entry is invalid: {exc}") from exc + + +def load_campaign_entries(config: CampaignConfig, *, campaign_file: str | Path) -> list[EntryConfig]: + """Load and normalize campaign entries from inline data or external CSV/JSON source. + + The normalized output is always a list of EntryConfig instances. This is intentionally + UI/API friendly: a future web interface can generate the same JSON structure and use the + same resolver without code changes. + """ + + if config.entries.inline is not None: + return list(config.entries.inline) + + if config.entries.source is None or config.entries.mapping is None: + raise EntryLoadError("external entries require source and mapping") + + source = config.entries.source + path = _resolve(campaign_file, source.path) + if not path.exists(): + raise EntryLoadError(f"entries source file does not exist: {path}") + + if source.type == SourceType.CSV: + if not source.has_header: + raise EntryLoadError("CSV entries currently require has_header=true") + rows = _load_csv_rows(path, delimiter=source.delimiter, encoding=source.encoding) + elif source.type == SourceType.JSON: + rows = _load_json_rows(path, encoding=source.encoding) + else: # pragma: no cover - defensive; Pydantic constrains this already. + raise EntryLoadError(f"unsupported entries source type: {source.type}") + + defaults_data = _entry_defaults_data(config) + return [_row_to_entry(defaults_data, config.entries.mapping, row, index + 2) for index, row in enumerate(rows)] diff --git a/src/govoplan_campaign/backend/campaign/field_values.py b/src/govoplan_campaign/backend/campaign/field_values.py new file mode 100644 index 0000000..ddd2011 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/field_values.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from typing import Any + +from .models import CampaignConfig, EntryConfig, FieldDefinition + + +def field_definitions_by_name(config: CampaignConfig) -> dict[str, FieldDefinition]: + """Return campaign field definitions keyed by field id/name.""" + + return {field.name: field for field in config.fields} + + +def field_can_override(config: CampaignConfig, field_name: str) -> bool: + """Return whether a recipient/entry value may override the global value. + + Unknown fields remain overridable for backwards compatibility with older + campaigns and ad-hoc external mappings. Semantic validation reports unknown + field usage separately when a field list is configured. + """ + + field = field_definitions_by_name(config).get(field_name) + if field is None: + return True + return field.can_override + + +def ignored_entry_field_overrides(config: CampaignConfig, entry: EntryConfig) -> list[str]: + """Return recipient field keys that are ignored by the override policy.""" + + return sorted(name for name in entry.fields if not field_can_override(config, name)) + + +def effective_entry_field_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]: + """Return the local/effective field value map for one message entry. + + Global values act as defaults for local template placeholders. Recipient + values replace those defaults only when the corresponding field allows + overrides. Fields that are unknown to the campaign definition keep the old + permissive behavior and remain usable as local values. + """ + + values: dict[str, Any] = dict(config.global_values) + for key, value in entry.fields.items(): + if field_can_override(config, key) and entry_field_has_override_value(value): + values[key] = value + return values + + +def entry_field_has_override_value(value: Any) -> bool: + """Return whether an entry field should override a global default. + + Empty recipient values are treated as "not set" so global_values remain the + effective local defaults. Numeric zero and boolean false are valid explicit + overrides. + """ + + if value is None: + return False + if isinstance(value, str): + return value.strip() != "" + return True diff --git a/src/govoplan_campaign/backend/campaign/loader.py b/src/govoplan_campaign/backend/campaign/loader.py new file mode 100644 index 0000000..7721810 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/loader.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker + +from .models import CampaignConfig + + +class CampaignLoadError(ValueError): + """Raised when the campaign JSON cannot be loaded or parsed.""" + + +@dataclass(frozen=True) +class SchemaValidationError: + path: str + message: str + + +class CampaignSchemaError(CampaignLoadError): + def __init__(self, errors: list[SchemaValidationError]) -> None: + self.errors = errors + details = "; ".join(f"{error.path}: {error.message}" for error in errors[:5]) + if len(errors) > 5: + details += f"; ... and {len(errors) - 5} more" + super().__init__(f"campaign schema validation failed: {details}") + + +def load_campaign_json(path: str | Path) -> dict[str, Any]: + campaign_path = Path(path) + try: + with campaign_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except OSError as exc: + raise CampaignLoadError(f"could not read campaign JSON {campaign_path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise CampaignLoadError(f"invalid campaign JSON {campaign_path}: {exc}") from exc + if not isinstance(data, dict): + raise CampaignLoadError("campaign JSON root must be an object") + return data + + +def _default_schema_path() -> Path: + return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.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 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()) + errors = sorted(validator.iter_errors(data), key=lambda error: list(error.path)) + if errors: + normalized = [ + SchemaValidationError( + path="/" + "/".join(str(part) for part in error.absolute_path), + message=error.message, + ) + for error in errors + ] + raise CampaignSchemaError(normalized) + + +def load_campaign_config( + path: str | Path, + *, + validate_schema: bool = True, + schema_path: str | Path | None = None, +) -> CampaignConfig: + data = load_campaign_json(path) + if validate_schema: + validate_against_schema(data, schema_path=schema_path) + return CampaignConfig.model_validate(data) diff --git a/src/govoplan_campaign/backend/campaign/models.py b/src/govoplan_campaign/backend/campaign/models.py new file mode 100644 index 0000000..0d03742 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/models.py @@ -0,0 +1,551 @@ +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + +class CampaignMode(StrEnum): + DRAFT = "draft" + TEST = "test" + SEND = "send" + + +class FieldType(StrEnum): + STRING = "string" + INTEGER = "integer" + DOUBLE = "double" + DATE = "date" + PASSWORD = "password" + + +class RecipientType(StrEnum): + TO = "to" + CC = "cc" + BCC = "bcc" + REPLY_TO = "reply_to" + BOUNCE_TO = "bounce_to" + DISPOSITION_NOTIFICATION_TO = "disposition_notification_to" + + +class Behavior(StrEnum): + BLOCK = "block" + ASK = "ask" + DROP = "drop" + CONTINUE = "continue" + WARN = "warn" + + +class MissingAddressBehavior(StrEnum): + BLOCK = "block" + DROP = "drop" + + +class InactiveEntryBehavior(StrEnum): + DROP = "drop" + BLOCK = "block" + WARN = "warn" + + +class SourceType(StrEnum): + CSV = "csv" + JSON = "json" + + +class ZipMethod(StrEnum): + ZIP_STANDARD = "zip_standard" + AES = "aes" + + +class ZipRuleMode(StrEnum): + INHERIT = "inherit" + INCLUDE = "include" + EXCLUDE = "exclude" + + +class ZipPasswordScope(StrEnum): + LOCAL = "local" + GLOBAL = "global" + + +class ZipPasswordMode(StrEnum): + NONE = "none" + DIRECT = "direct" + FIELD = "field" + TEMPLATE = "template" + + +class BuildStatus(StrEnum): + BUILT = "built" + BUILD_FAILED = "build_failed" + + +class SendStatus(StrEnum): + DRAFT = "draft" + QUEUED = "queued" + + +class CampaignMeta(StrictModel): + id: str + name: str + description: str | None = None + mode: CampaignMode = CampaignMode.DRAFT + + +class FieldDefinition(StrictModel): + name: str + type: FieldType = FieldType.STRING + label: str | None = None + required: bool = False + can_override: bool = True + + +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 + + +class RecipientConfig(StrictModel): + email: str + name: str | None = None + recipient_type: RecipientType = Field(default=RecipientType.TO, alias="type") + + @field_validator("email") + @classmethod + def email_should_look_like_address(cls, value: str) -> str: + # JSON Schema's format=email remains the stricter validation layer. + # Keep this deliberately lightweight to avoid an extra email-validator dependency. + if "@" not in value: + raise ValueError("email must contain '@'") + return value + + +class RecipientsConfig(StrictModel): + from_: list[RecipientConfig] = Field(default_factory=list, alias="from", max_length=1) + + @field_validator("from_", mode="before") + @classmethod + def normalize_from_list(cls, value: Any) -> Any: + # Older campaign files stored From as one object. The canonical model + # is now an array so every address header has the same list semantics. + if value is None: + return [] + if isinstance(value, dict): + return [] if not any(value.values()) else [value] + return value + + allow_individual_from: bool = False + + to: list[RecipientConfig] = Field(default_factory=list) + allow_individual_to: bool = False + + cc: list[RecipientConfig] = Field(default_factory=list) + allow_individual_cc: bool = False + + bcc: list[RecipientConfig] = Field(default_factory=list) + allow_individual_bcc: bool = False + + reply_to: list[RecipientConfig] = Field(default_factory=list) + allow_individual_reply_to: bool = False + + bounce_to: list[RecipientConfig] = Field(default_factory=list) + allow_individual_bounce_to: bool = False + + disposition_notification_to: list[RecipientConfig] = Field(default_factory=list) + allow_individual_disposition_notification_to: bool = False + + +class TemplateSourceConfig(StrictModel): + type: Literal["files"] = "files" + subject_path: str | None = None + text_path: str | None = None + html_path: str | None = None + encoding: str = "utf-8" + + @model_validator(mode="after") + def at_least_one_path(self) -> "TemplateSourceConfig": + if not any([self.subject_path, self.text_path, self.html_path]): + raise ValueError("template.source must define subject_path, text_path or html_path") + return self + + +class TemplateConfig(StrictModel): + subject: str | None = None + text: str | None = None + html: str | None = None + source: TemplateSourceConfig | None = None + + @model_validator(mode="after") + def inline_or_source(self) -> "TemplateConfig": + inline_values = any(value is not None for value in [self.subject, self.text, self.html]) + if self.source and inline_values: + raise ValueError("template must be either inline or source-based, not both") + if self.source: + return self + if not self.subject: + raise ValueError("inline template requires subject") + return self + + @property + def is_external(self) -> bool: + return self.source is not None + + +class ZipArchiveConfig(StrictModel): + id: str + name: str = "attachments.zip" + standard: bool = False + password_enabled: bool = False + password_field: str | None = None + password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL + method: ZipMethod = ZipMethod.AES + + # Compatibility fields for campaigns created by the first single-archive + # implementation. New WebUI campaigns use password_enabled/field/scope. + password_mode: ZipPasswordMode | None = None + password: str | None = None + password_template: str | None = None + + @model_validator(mode="before") + @classmethod + def normalize_legacy_archive(cls, value: Any) -> Any: + if not isinstance(value, dict): + return value + normalized = dict(value) + if "name" not in normalized and normalized.get("filename_template"): + normalized["name"] = normalized.get("filename_template") + normalized.pop("filename_template", None) + mode = normalized.get("password_mode") + if "password_enabled" not in normalized and mode in { + ZipPasswordMode.DIRECT.value, + ZipPasswordMode.FIELD.value, + ZipPasswordMode.TEMPLATE.value, + }: + normalized["password_enabled"] = True + if mode == ZipPasswordMode.FIELD.value and "password_scope" not in normalized: + normalized["password_scope"] = ZipPasswordScope.LOCAL.value + return normalized + + +class ZipCollectionConfig(StrictModel): + enabled: bool = False + archives: list[ZipArchiveConfig] = Field(default_factory=list) + + @model_validator(mode="before") + @classmethod + def normalize_single_archive_config(cls, value: Any) -> Any: + """Upgrade the original single-recipient-ZIP object in memory. + + The campaign JSON remains portable: old filename/password settings are + converted to one standard archive, while the new format is passed + through unchanged. + """ + + if not isinstance(value, dict) or "archives" in value: + return value + legacy = dict(value) + enabled = bool(legacy.get("enabled")) + meaningful = enabled or any( + legacy.get(key) not in (None, "", False, "none", "inherit") + for key in ("filename_template", "password_mode", "password", "password_field", "password_template") + ) + if not meaningful: + return {"enabled": False, "archives": []} + archive = { + "id": "default", + "name": legacy.get("filename_template") or "attachments.zip", + "standard": True, + "method": legacy.get("method", ZipMethod.AES.value), + "password_mode": legacy.get("password_mode"), + "password": legacy.get("password"), + "password_field": legacy.get("password_field"), + "password_template": legacy.get("password_template"), + } + mode = legacy.get("password_mode") + if not mode and legacy.get("password_template"): + mode = ZipPasswordMode.TEMPLATE.value + archive["password_mode"] = mode + archive["password_enabled"] = mode in { + ZipPasswordMode.DIRECT.value, + ZipPasswordMode.FIELD.value, + ZipPasswordMode.TEMPLATE.value, + } + archive["password_scope"] = ZipPasswordScope.LOCAL.value + return {"enabled": enabled, "archives": [archive]} + + @property + def standard_archive(self) -> ZipArchiveConfig | None: + return next((archive for archive in self.archives if archive.standard), self.archives[0] if self.archives else None) + + def archive_by_id(self, archive_id: str | None) -> ZipArchiveConfig | None: + if not archive_id: + return None + return next((archive for archive in self.archives if archive.id == archive_id), None) + + +class ZipRuleConfig(StrictModel): + # New format: inherit the campaign standard, select an archive id, or use + # the reserved value "exclude" to send the file outside every archive. + archive_id: str = ZipRuleMode.INHERIT.value + + # Original per-rule fields remain accepted for imported campaign JSON. + enabled: bool = False + mode: ZipRuleMode = ZipRuleMode.INHERIT + filename_template: str | None = None + password_mode: ZipPasswordMode = ZipPasswordMode.NONE + password: str | None = None + password_field: str | None = None + password_template: str | None = None + method: ZipMethod = ZipMethod.AES + + @model_validator(mode="before") + @classmethod + def normalize_legacy_password_mode(cls, value: Any) -> Any: + if isinstance(value, dict) and value.get("password_template") and "password_mode" not in value: + return {**value, "password_mode": ZipPasswordMode.TEMPLATE.value} + return value + + +class AttachmentBasePathConfig(StrictModel): + id: str | None = None + name: str + path: str = "." + allow_individual: bool = False + unsent_warning: bool = False + # Legacy UI builds briefly wrote a source value. Keep accepting it so older + # drafts do not become invalid merely because the current UI no longer shows + # or edits that column. + source: str | None = None + + +class AttachmentConfig(StrictModel): + id: str | None = None + label: str | None = None + base_path_id: str | None = None + # Legacy UI helper. Current attachment resolution ignores this value and + # treats direct files as plain file_filter patterns without wildcards. + # Keep accepting it so existing drafts with {"type": ""}, "direct" + # or "pattern" remain valid. + type_: str | None = Field(default=None, alias="type") + base_dir: str + file_filter: str + include_subdirs: bool = False + required: bool = True + allow_multiple: bool = False + + @field_validator("type_", mode="before") + @classmethod + def empty_type_means_unset(cls, value: Any) -> Any: + if value == "": + return None + return value + + # None means: inherit from validation_policy. Explicit values remain + # supported for backwards compatibility and per-rule overrides. + missing_behavior: Behavior | None = None + ambiguous_behavior: Behavior | None = None + zip: ZipRuleConfig = Field(default_factory=ZipRuleConfig) + + +class AttachmentsConfig(StrictModel): + base_path: str = "." + base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list) + allow_individual: bool = False + send_without_attachments: bool = True + zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig) + global_: list[AttachmentConfig] = Field(default_factory=list, alias="global") + missing_behavior: Behavior = Behavior.ASK + ambiguous_behavior: Behavior = Behavior.ASK + + @property + def individual_base_path_values(self) -> set[str]: + return {base_path.path for base_path in self.base_paths if base_path.allow_individual} + + +class EntryConfig(StrictModel): + id: str | None = None + active: bool = True + + @model_validator(mode="before") + @classmethod + def normalize_legacy_combine_flags(cls, value: Any) -> Any: + if not isinstance(value, dict): + return value + normalized = dict(value) + for address_field in ("from", "to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"): + merge_key = f"merge_{address_field}" + combine_key = f"combine_{address_field}" + if merge_key not in normalized and combine_key in normalized: + normalized[merge_key] = normalized[combine_key] + normalized.pop(combine_key, None) + return normalized + + # Compatibility fields written by older/current WebUI recipient rows. + # Address routing uses the explicit to/cc/bcc/reply_to/from fields below; + # these values are retained for round-tripping but are not used for sending. + name: str | None = None + email: str | None = None + + from_: list[RecipientConfig] = Field(default_factory=list, alias="from", max_length=1) + merge_from: bool = False # Deprecated compatibility field; From never merges. + + @field_validator("from_", mode="before") + @classmethod + def normalize_from_list(cls, value: Any) -> Any: + if value is None: + return [] + if isinstance(value, dict): + return [] if not any(value.values()) else [value] + return value + + to: list[RecipientConfig] = Field(default_factory=list) + merge_to: bool = True + + cc: list[RecipientConfig] = Field(default_factory=list) + merge_cc: bool = True + + bcc: list[RecipientConfig] = Field(default_factory=list) + merge_bcc: bool = True + + reply_to: list[RecipientConfig] = Field(default_factory=list) + merge_reply_to: bool = True + + bounce_to: list[RecipientConfig] = Field(default_factory=list) + merge_bounce_to: bool = True + + disposition_notification_to: list[RecipientConfig] = Field(default_factory=list) + merge_disposition_notification_to: bool = True + + attachments: list[AttachmentConfig] = Field(default_factory=list) + combine_attachments: bool = True + + fields: dict[str, Any] = Field(default_factory=dict) + last_sent: str | None = None + + +class SourceConfig(StrictModel): + type: SourceType + path: str + delimiter: str = ";" + encoding: str = "utf-8" + has_header: bool = True + + +class EntriesConfig(StrictModel): + inline: list[EntryConfig] | None = None + source: SourceConfig | None = None + mapping: dict[str, str] | None = None + defaults: EntryConfig | None = None + + @model_validator(mode="after") + def inline_or_external(self) -> "EntriesConfig": + has_inline = self.inline is not None + has_external_source = self.source is not None or self.mapping is not None + # defaults are compatible with both inline and external entries. The + # WebUI stores the current per-entry combination defaults here even for + # inline campaigns, so treating defaults as an external-source marker + # made valid UI drafts fail backend validation. + if has_inline and has_external_source: + raise ValueError("entries must be either inline or source-based, not both") + if has_inline: + return self + if self.source is None or self.mapping is None: + raise ValueError("external entries require source and mapping") + return self + + @property + def is_inline(self) -> bool: + return self.inline is not None + + @property + def is_external(self) -> bool: + return self.source is not None + + +class ValidationPolicy(StrictModel): + missing_required_attachment: Behavior = Behavior.ASK + missing_optional_attachment: Behavior = Behavior.WARN + ambiguous_attachment_match: Behavior = Behavior.ASK + ignore_empty_fields: bool = False + unsent_attachment_files: Behavior = Behavior.WARN + missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK + template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK + inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP + + +class RateLimitConfig(StrictModel): + messages_per_minute: int = Field(default=5, ge=1) + concurrency: int = Field(default=1, ge=1) + + +class ImapAppendSentConfig(StrictModel): + enabled: bool = False + folder: str = "auto" + + +class RetryConfig(StrictModel): + max_attempts: int = Field(default=3, ge=1) + backoff_seconds: list[int] = Field(default_factory=lambda: [60, 300, 900]) + + @field_validator("backoff_seconds") + @classmethod + def backoff_values_must_be_positive(cls, values: list[int]) -> list[int]: + if any(value < 1 for value in values): + raise ValueError("backoff_seconds values must be >= 1") + return values + + +class DeliveryConfig(StrictModel): + rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig) + imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig) + retry: RetryConfig = Field(default_factory=RetryConfig) + + +class StatusTrackingConfig(StrictModel): + enabled: bool = True + initial_build_status: BuildStatus = BuildStatus.BUILT + initial_send_status: SendStatus = SendStatus.DRAFT + + +class CampaignConfig(StrictModel): + version: Literal["1.0"] + campaign: CampaignMeta + fields: list[FieldDefinition] = Field(default_factory=list) + global_values: dict[str, Any] = Field(default_factory=dict) + server: ServerConfig = Field(default_factory=ServerConfig) + recipients: RecipientsConfig = Field(default_factory=RecipientsConfig) + template: TemplateConfig + attachments: AttachmentsConfig = Field(default_factory=AttachmentsConfig) + entries: EntriesConfig + validation_policy: ValidationPolicy = Field(default_factory=ValidationPolicy) + delivery: DeliveryConfig = Field(default_factory=DeliveryConfig) + status_tracking: StatusTrackingConfig = Field(default_factory=StatusTrackingConfig) + + @model_validator(mode="after") + def field_names_must_be_unique(self) -> "CampaignConfig": + names = [field.name for field in self.fields] + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError(f"duplicate field definitions: {', '.join(duplicates)}") + return self + + @property + def field_names(self) -> set[str]: + return {field.name for field in self.fields} + + def resolve_relative_path(self, campaign_file: Path, raw_path: str) -> Path: + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_file.parent / path).resolve() diff --git a/src/govoplan_campaign/backend/campaign/template_values.py b/src/govoplan_campaign/backend/campaign/template_values.py new file mode 100644 index 0000000..132c827 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/template_values.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +from .addressing import effective_address_lists, recipient_template_values +from .field_values import effective_entry_field_values +from .models import CampaignConfig, EntryConfig + + +def build_template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]: + values: dict[str, Any] = {} + for field in config.fields: + values.setdefault(field.name, "") + values.setdefault(f"global::{field.name}", "") + values.setdefault(f"local::{field.name}", "") + for key, value in config.global_values.items(): + values[f"global::{key}"] = value + for key, value in effective_entry_field_values(config, entry).items(): + values[key] = value + values[f"local::{key}"] = value + if entry.id: + values["local::id"] = entry.id + values["local::active"] = entry.active + values.update(recipient_template_values(effective_address_lists(config, entry))) + return values diff --git a/src/govoplan_campaign/backend/campaign/validation.py b/src/govoplan_campaign/backend/campaign/validation.py new file mode 100644 index 0000000..d3a46c7 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/validation.py @@ -0,0 +1,437 @@ +from __future__ import annotations + +import csv +from enum import StrEnum +from pathlib import Path +from typing import Iterable + +from pydantic import BaseModel, ConfigDict, Field + +from .field_values import ignored_entry_field_overrides +from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipPasswordMode, ZipPasswordScope, ZipRuleMode + + +class Severity(StrEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class SemanticIssue(BaseModel): + model_config = ConfigDict(extra="forbid") + + severity: Severity + code: str + message: str + path: str | None = None + + +class SemanticReport(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_id: str + campaign_name: str + issues: list[SemanticIssue] = Field(default_factory=list) + entries_mode: str + entries_count: int | None = None + attachments_base_path: str + rate_limit: str + imap_append_enabled: bool + + @property + def error_count(self) -> int: + return sum(1 for issue in self.issues if issue.severity == Severity.ERROR) + + @property + def warning_count(self) -> int: + return sum(1 for issue in self.issues if issue.severity == Severity.WARNING) + + @property + def ok(self) -> bool: + return self.error_count == 0 + + +def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue: + return SemanticIssue(severity=severity, code=code, message=message, path=path) + + +def _resolve(campaign_file: Path, raw_path: str) -> Path: + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_file.parent / path).resolve() + + +def _mapping_target_field_name(target: str) -> str | None: + if target.startswith("fields."): + return target.split(".", 1)[1] + return None + + +def _mapping_target_known(target: str, field_names: set[str]) -> bool: + direct_targets = { + "id", + "active", + "last_sent", + "merge_from", + "merge_to", + "merge_cc", + "merge_bcc", + "merge_reply_to", + "merge_bounce_to", + "merge_disposition_notification_to", + "combine_to", + "combine_cc", + "combine_bcc", + "combine_reply_to", + "combine_bounce_to", + "combine_disposition_notification_to", + "combine_attachments", + } + if target in direct_targets: + return True + if target.startswith("fields."): + name = target.split(".", 1)[1] + return not field_names or name in field_names + if target.startswith("from."): + return target in {"from.email", "from.name", "from.type"} + for prefix in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]: + if target.startswith(prefix + "."): + parts = target.split(".") + return len(parts) == 3 and parts[1].isdigit() and parts[2] in {"email", "name", "type"} + if target.startswith("attachments."): + parts = target.split(".") + # attachments.0.zip.filename_template etc. + if len(parts) >= 3 and parts[1].isdigit(): + if parts[2] in { + "id", + "label", + "base_dir", + "file_filter", + "include_subdirs", + "required", + "allow_multiple", + "missing_behavior", + "ambiguous_behavior", + }: + return len(parts) == 3 + if parts[2] == "zip" and len(parts) == 4: + return parts[3] in {"enabled", "mode", "filename_template", "password_mode", "password", "password_field", "password_template", "method"} + return False + + +def _csv_header(path: Path, delimiter: str, encoding: str) -> list[str] | None: + with path.open("r", encoding=encoding, newline="") as handle: + reader = csv.reader(handle, delimiter=delimiter) + try: + return next(reader) + except StopIteration: + return [] + + +def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, str]]: + if not config.template.source: + return [] + source = config.template.source + paths: list[tuple[str, str]] = [] + if source.subject_path: + paths.append(("/template/source/subject_path", source.subject_path)) + if source.text_path: + paths.append(("/template/source/text_path", source.text_path)) + if source.html_path: + paths.append(("/template/source/html_path", source.html_path)) + return paths + + +def _attachment_base_path_report_value(config: CampaignConfig) -> str: + if config.attachments.base_paths: + return ", ".join(f"{base_path.name}: {base_path.path}" for base_path in config.attachments.base_paths) + return config.attachments.base_path + + +def _iter_attachment_rules(config: CampaignConfig) -> Iterable[tuple[str, AttachmentConfig, bool]]: + for index, attachment_config in enumerate(config.attachments.global_): + yield f"/attachments/global/{index}", attachment_config, False + + inline_entries = config.entries.inline or [] if config.entries.is_inline else [] + for entry_index, entry in enumerate(inline_entries): + if not entry.active: + continue + for attachment_index, attachment_config in enumerate(entry.attachments): + yield f"/entries/inline/{entry_index}/attachments/{attachment_index}", attachment_config, True + + if config.entries.defaults: + for attachment_index, attachment_config in enumerate(config.entries.defaults.attachments): + yield f"/entries/defaults/attachments/{attachment_index}", attachment_config, True + + +def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]: + issues: list[SemanticIssue] = [] + configured_paths = {base_path.path for base_path in config.attachments.base_paths} + individual_paths = config.attachments.individual_base_path_values + + if config.attachments.base_paths: + for index, base_path in enumerate(config.attachments.base_paths): + if not base_path.name.strip(): + issues.append(_issue(Severity.WARNING, "attachment_base_path_missing_name", "attachment base path has no display name", f"/attachments/base_paths/{index}/name")) + if not base_path.path.strip(): + issues.append(_issue(Severity.ERROR, "attachment_base_path_missing_path", "attachment base path has no path", f"/attachments/base_paths/{index}/path")) + elif not config.attachments.base_path: + issues.append(_issue(Severity.INFO, "missing_attachment_base_path", "Attachment base path is not configured yet.", "/attachments/base_path")) + + if configured_paths: + for path, attachment_config, is_individual in _iter_attachment_rules(config): + if attachment_config.base_dir and attachment_config.base_dir not in configured_paths: + issues.append(_issue( + Severity.WARNING, + "unknown_attachment_base_path", + f"attachment rule refers to base path {attachment_config.base_dir!r}, but it is not listed in attachments.base_paths", + f"{path}/base_dir", + )) + if is_individual and individual_paths and attachment_config.base_dir not in individual_paths: + issues.append(_issue( + Severity.WARNING, + "individual_attachment_base_path_not_allowed", + f"individual attachment rule uses base path {attachment_config.base_dir!r}, but that base path does not allow individual attachments", + f"{path}/base_dir", + )) + return issues + + +def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]: + collection = config.attachments.zip + issues: list[SemanticIssue] = [] + if not collection.enabled: + return issues + if not collection.archives: + return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")] + + archive_ids: set[str] = set() + archive_names: set[str] = set() + standard_count = 0 + field_definitions = {field.name: field for field in config.fields} + for index, archive in enumerate(collection.archives): + path = f"/attachments/zip/archives/{index}" + if not archive.id.strip(): + issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id")) + elif archive.id in archive_ids: + issues.append(_issue(Severity.ERROR, "zip_archive_id_duplicate", f"ZIP archive id {archive.id!r} is used more than once", f"{path}/id")) + archive_ids.add(archive.id) + normalized_archive_name = _normalized_zip_archive_name(archive.name) + if not normalized_archive_name: + issues.append(_issue(Severity.ERROR, "zip_archive_name_missing", "ZIP archive has no filename", f"{path}/name")) + elif normalized_archive_name in archive_names: + issues.append(_issue(Severity.ERROR, "zip_archive_name_duplicate", f"ZIP archive filename {archive.name!r} is used more than once; archive filenames must be unique", f"{path}/name")) + archive_names.add(normalized_archive_name) + if archive.standard: + standard_count += 1 + + if not archive.password_enabled: + continue + if archive.password_mode == ZipPasswordMode.DIRECT: + if not (archive.password or ""): + issues.append(_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password")) + continue + if archive.password_mode == ZipPasswordMode.TEMPLATE: + if not (archive.password_template or ""): + issues.append(_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template")) + continue + + field_name = (archive.password_field or "").strip() + field = field_definitions.get(field_name) + if not field_name: + issues.append(_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field")) + elif field is None: + issues.append(_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field")) + elif field.type != FieldType.PASSWORD: + issues.append(_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field")) + elif archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""): + issues.append(_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}")) + + if standard_count != 1: + issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives")) + + for path, rule, _is_individual in _iter_attachment_rules(config): + selection = (rule.zip.archive_id or ZipRuleMode.INHERIT.value).strip() + if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value, ZipRuleMode.EXCLUDE.value} and selection not in archive_ids: + issues.append(_issue(Severity.ERROR, "zip_archive_unknown", f"Attachment rule selects unknown ZIP archive {selection!r}", f"{path}/zip/archive_id")) + return issues + + +def _normalized_zip_archive_name(value: str) -> str: + normalized = value.strip().casefold() + if not normalized: + return "" + return normalized if normalized.endswith(".zip") else f"{normalized}.zip" + +def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]: + return [ + _issue( + Severity.WARNING, + "field_override_not_allowed", + f"recipient value for field {field_name!r} will be ignored because the field does not allow overrides", + f"{path_prefix}/fields/{field_name}", + ) + for field_name in ignored_entry_field_overrides(config, entry) + ] + + +def validate_campaign_config( + config: CampaignConfig, + *, + campaign_file: str | Path | None = None, + check_files: bool = False, +) -> SemanticReport: + campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json" + issues: list[SemanticIssue] = [] + + field_names = config.field_names + field_definitions = {field.name: field for field in config.fields} + declared_names = set(field_definitions) + + for key in config.global_values: + if declared_names and key not in declared_names: + issues.append(_issue( + Severity.WARNING, + "unknown_global_value", + f"global_values contains {key!r}, but it is not declared in fields", + f"/global_values/{key}", + )) + + issues.extend(_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: + 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: + issues.append(_issue( + Severity.ERROR, + "missing_smtp_config", + "campaign mode is 'send', but no server.smtp configuration is present", + "/server/smtp", + )) + + if config.server.smtp: + missing = [name for name in ["host", "port"] if getattr(config.server.smtp, name) in (None, "")] + if missing: + issues.append(_issue( + Severity.WARNING, + "incomplete_smtp_config", + "SMTP settings are present, but these settings are missing: " + ", ".join(missing), + "/server/smtp", + )) + + if config.entries.is_inline: + inline_entries = config.entries.inline or [] + entries_count = len(inline_entries) + entries_mode = "inline" + if entries_count == 0: + issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline")) + for index, entry in enumerate(inline_entries): + if entry.active: + issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}")) + else: + entries_count = None + entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}" + mapping = config.entries.mapping or {} + if not mapping: + issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping")) + for target in mapping: + if not _mapping_target_known(target, field_names): + issues.append(_issue( + Severity.WARNING, + "unknown_mapping_target", + f"mapping target {target!r} is not recognized by the current campaign model", + f"/entries/mapping/{target}", + )) + field_name = _mapping_target_field_name(target) + if field_name and field_name in field_definitions and not field_definitions[field_name].can_override: + issues.append(_issue( + Severity.WARNING, + "mapping_target_not_overridable", + f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored", + f"/entries/mapping/{target}", + )) + if config.entries.defaults: + issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults")) + if check_files and config.entries.source: + source_path = _resolve(campaign_path, config.entries.source.path) + if not source_path.exists(): + issues.append(_issue( + Severity.ERROR, + "entries_source_not_found", + f"entries source file does not exist: {source_path}", + "/entries/source/path", + )) + elif config.entries.source.type == SourceType.CSV and config.entries.source.has_header: + try: + header = _csv_header(source_path, config.entries.source.delimiter, config.entries.source.encoding) + header_set = set(header or []) + missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set}) + if missing_columns: + issues.append(_issue( + Severity.ERROR, + "mapping_columns_missing", + "CSV mapping refers to missing columns: " + ", ".join(missing_columns), + "/entries/mapping", + )) + except OSError as exc: + issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path")) + + if check_files: + if config.attachments.base_paths: + for index, base_path_config in enumerate(config.attachments.base_paths): + attachments_base_path = _resolve(campaign_path, base_path_config.path) + if not attachments_base_path.exists(): + issues.append(_issue( + Severity.WARNING, + "attachments_base_path_not_found", + f"attachment base path {base_path_config.name!r} does not exist: {attachments_base_path}", + f"/attachments/base_paths/{index}/path", + )) + else: + attachments_base_path = _resolve(campaign_path, config.attachments.base_path) + if not attachments_base_path.exists(): + issues.append(_issue( + Severity.WARNING, + "attachments_base_path_not_found", + f"attachments.base_path does not exist: {attachments_base_path}", + "/attachments/base_path", + )) + for schema_path, raw_path in _iter_template_source_paths(config): + path = _resolve(campaign_path, raw_path) + if not path.exists(): + issues.append(_issue( + Severity.ERROR, + "template_source_not_found", + f"template source file does not exist: {path}", + schema_path, + )) + + report = SemanticReport( + campaign_id=config.campaign.id, + campaign_name=config.campaign.name, + issues=issues, + entries_mode=entries_mode, + entries_count=entries_count, + attachments_base_path=_attachment_base_path_report_value(config), + rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}", + imap_append_enabled=config.delivery.imap_append_sent.enabled, + ) + return report diff --git a/src/govoplan_campaign/backend/db/__init__.py b/src/govoplan_campaign/backend/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/db/models.py b/src/govoplan_campaign/backend/db/models.py new file mode 100644 index 0000000..68628ce --- /dev/null +++ b/src/govoplan_campaign/backend/db/models.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import StrEnum +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from govoplan_core.db.base import Base, TimestampMixin + +try: + from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership +except (ImportError, ModuleNotFoundError): + pass + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class CampaignStatus(StrEnum): + DRAFT = "draft" + VALIDATED = "validated" + NEEDS_REVIEW = "needs_review" + READY_TO_QUEUE = "ready_to_queue" + QUEUED = "queued" + SENDING = "sending" + SENT = "sent" + PARTIALLY_COMPLETED = "partially_completed" + OUTCOME_UNKNOWN = "outcome_unknown" + FAILED = "failed" + CANCELLED = "cancelled" + ARCHIVED = "archived" + + +class CampaignVersionWorkflowState(StrEnum): + EDITING = "editing" + UNDER_REVIEW = "under_review" + APPROVED = "approved" + BUILT = "built" + QUEUED = "queued" + SENDING = "sending" + COMPLETED = "completed" + PARTIALLY_COMPLETED = "partially_completed" + OUTCOME_UNKNOWN = "outcome_unknown" + FAILED = "failed" + CANCELLED = "cancelled" + ARCHIVED = "archived" + + +class CampaignVersionFlow(StrEnum): + CREATE = "create" + REVIEW = "review" + SEND = "send" + MANUAL = "manual" + JSON = "json" + + +class JobBuildStatus(StrEnum): + PENDING = "pending" + BUILT = "built" + BUILD_FAILED = "build_failed" + + +class JobValidationStatus(StrEnum): + READY = "ready" + WARNING = "warning" + NEEDS_REVIEW = "needs_review" + BLOCKED = "blocked" + EXCLUDED = "excluded" + INACTIVE = "inactive" + + +class JobQueueStatus(StrEnum): + DRAFT = "draft" + QUEUED = "queued" + SENDING = "sending" + PAUSED = "paused" + CANCELLED = "cancelled" + + +class JobSendStatus(StrEnum): + NOT_QUEUED = "not_queued" + QUEUED = "queued" + CLAIMED = "claimed" + SENDING = "sending" + SMTP_ACCEPTED = "smtp_accepted" + SENT = "sent" # legacy value retained for existing databases/reports + OUTCOME_UNKNOWN = "outcome_unknown" + FAILED_TEMPORARY = "failed_temporary" + FAILED_PERMANENT = "failed_permanent" + CANCELLED = "cancelled" + + +class JobImapStatus(StrEnum): + NOT_REQUESTED = "not_requested" + PENDING = "pending" + APPENDED = "appended" + FAILED = "failed" + SKIPPED = "skipped" + + +class IssueSeverity(StrEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class Campaign(Base, TimestampMixin): + __tablename__ = "campaigns" + __table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True) + external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(50), default=CampaignStatus.DRAFT.value, nullable=False, index=True) + current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + tenant: Mapped[Tenant] = relationship() + versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan") + jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan") + + +class CampaignShare(Base, TimestampMixin): + __tablename__ = "campaign_shares" + __table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + + +class CampaignVersion(Base, TimestampMixin): + __tablename__ = "campaign_versions" + __table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False) + source_filename: Mapped[str | None] = mapped_column(String(500)) + source_base_path: Mapped[str | None] = mapped_column(String(1000)) + + # Editor/workflow metadata used by the WebUI and future desktop clients. + # A campaign version can be the autosaved working copy of a new or existing + # campaign, so no separate CampaignDraft entity is needed. + workflow_state: Mapped[str] = mapped_column( + String(50), + default=CampaignVersionWorkflowState.EDITING.value, + nullable=False, + index=True, + ) + current_flow: Mapped[str] = mapped_column( + String(50), + default=CampaignVersionFlow.MANUAL.value, + nullable=False, + index=True, + ) + current_step: Mapped[str | None] = mapped_column(String(100)) + is_complete: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + editor_state: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + # Explicit user-requested lock. This is deliberately separate from + # locked_at, which represents the reversible validation lock used by the + # build/send workflow. Temporary user locks may later receive a dedicated + # RBAC permission for unlocking; permanent locks never unlock in place. + user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + execution_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + execution_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + campaign: Mapped[Campaign] = relationship(back_populates="versions") + + +class CampaignJob(Base, TimestampMixin): + __tablename__ = "campaign_jobs" + __table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True) + entry_index: Mapped[int] = mapped_column(Integer, nullable=False) + entry_id: Mapped[str | None] = mapped_column(String(255), index=True) + + recipient_email: Mapped[str | None] = mapped_column(String(320), index=True) + subject: Mapped[str | None] = mapped_column(String(998)) + message_id_header: Mapped[str | None] = mapped_column(String(255)) + eml_storage_key: Mapped[str | None] = mapped_column(String(1000)) + eml_local_path: Mapped[str | None] = mapped_column(String(1000)) + eml_size_bytes: Mapped[int | None] = mapped_column(Integer) + eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True) + validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True) + queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True) + send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True) + imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True) + + attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + last_error: Mapped[str | None] = mapped_column(Text) + queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) + issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) + + campaign: Mapped[Campaign] = relationship(back_populates="jobs") + + +class CampaignIssue(Base, TimestampMixin): + __tablename__ = "campaign_issues" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True) + job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True) + severity: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + code: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + message: Mapped[str] = mapped_column(Text, nullable=False) + source: Mapped[str | None] = mapped_column(String(255)) + behavior: Mapped[str | None] = mapped_column(String(50)) + + +class AttachmentBlob(Base, TimestampMixin): + __tablename__ = "attachment_blobs" + __table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + mime_type: Mapped[str | None] = mapped_column(String(255)) + storage_bucket: Mapped[str] = mapped_column(String(255), nullable=False) + storage_key: Mapped[str] = mapped_column(String(1000), nullable=False) + + +class AttachmentInstance(Base, TimestampMixin): + __tablename__ = "attachment_instances" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True) + blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True) + logical_name: Mapped[str | None] = mapped_column(String(500)) + filename: Mapped[str] = mapped_column(String(500), nullable=False) + tags: Mapped[list[str]] = mapped_column(JSON, default=list) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + +class SendAttempt(Base, TimestampMixin): + __tablename__ = "send_attempts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[str] = mapped_column(String(50), default="started", nullable=False, index=True) + claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + smtp_status_code: Mapped[int | None] = mapped_column(Integer) + smtp_response: Mapped[str | None] = mapped_column(Text) + error_type: Mapped[str | None] = mapped_column(String(255)) + error_message: Mapped[str | None] = mapped_column(Text) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ImapAppendAttempt(Base, TimestampMixin): + __tablename__ = "imap_append_attempts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) + folder: Mapped[str | None] = mapped_column(String(500)) + status: Mapped[str] = mapped_column(String(50), nullable=False) + error_message: Mapped[str | None] = mapped_column(Text) + + + + +__all__ = [ + "AttachmentBlob", + "AttachmentInstance", + "Campaign", + "CampaignIssue", + "CampaignJob", + "CampaignShare", + "CampaignStatus", + "CampaignVersion", + "CampaignVersionFlow", + "CampaignVersionWorkflowState", + "Group", + "ImapAppendAttempt", + "IssueSeverity", + "JobBuildStatus", + "JobImapStatus", + "JobQueueStatus", + "JobSendStatus", + "JobValidationStatus", + "SendAttempt", + "Tenant", + "User", + "UserGroupMembership", +] diff --git a/src/govoplan_campaign/backend/dev/__init__.py b/src/govoplan_campaign/backend/dev/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/dev/mock_campaign.py b/src/govoplan_campaign/backend/dev/mock_campaign.py new file mode 100644 index 0000000..4c29233 --- /dev/null +++ b/src/govoplan_campaign/backend/dev/mock_campaign.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from email import policy +from email.message import EmailMessage +from typing import Any + +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.db.models import Campaign, CampaignVersion +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, +) + + +class MockCampaignSendError(RuntimeError): + pass + + +def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None: + if address is None: + return None + return {"email": address.email, "name": address.name} + + +def _message_addresses_payload(addresses: list[MessageAddress]) -> list[dict[str, Any]]: + return [{"email": item.email, "name": item.name} for item in addresses] + + +def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]: + return [issue.model_dump(mode="json") for issue in message.issues] + + +def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]: + return [public_attachment_summary_payload(attachment) for attachment in message.attachments] + + +def _message_payload(message: MessageDraft) -> dict[str, Any]: + return { + "entry_index": message.entry_index, + "entry_id": message.entry_id, + "active": message.active, + "subject": message.subject, + "from": _message_address_payload(message.from_), + "from_all": _message_addresses_payload(message.from_all), + "to": _message_addresses_payload(message.to), + "cc": _message_addresses_payload(message.cc), + "bcc": _message_addresses_payload(message.bcc), + "reply_to": _message_addresses_payload(message.reply_to), + "build_status": str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status), + "validation_status": message.validation_status.value, + "send_status": str(message.send_status.value if hasattr(message.send_status, "value") else message.send_status), + "imap_status": message.imap_status.value, + "attachment_count": message.attachment_count, + "attachments": _attachment_payloads(message), + "issues": _issue_payloads(message), + "eml_size_bytes": message.eml_size_bytes, + "queueable": message.is_queueable, + } + + +def _recipient_emails(message: MessageDraft) -> list[str]: + values = [item.email for item in message.to + message.cc + message.bcc if item.email] + return list(dict.fromkeys(values)) + + +def _envelope_from(message: MessageDraft, *, fallback: str = "mock-sender@mock.local") -> str: + if message.bounce_to: + return message.bounce_to[0].email + if message.from_ and message.from_.email: + return message.from_.email + return fallback + + +def _raw_message_bytes(message: EmailMessage) -> bytes: + return message.as_bytes(policy=policy.SMTP) + + +def _smtp_rejection_matches(recipients: list[str]) -> list[str]: + needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower() + if not needle: + return [] + return [recipient for recipient in recipients if needle in recipient.lower()] + + +def _can_mock_send( + message: MessageDraft, + *, + include_warnings: bool, + include_needs_review: bool, +) -> tuple[bool, str | None]: + if not message.active: + return False, "Recipient is inactive" + if str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status) != "built": + return False, f"Message is not built ({message.build_status})" + if message.validation_status == MessageValidationStatus.READY: + return True, None + if message.validation_status == MessageValidationStatus.WARNING and include_warnings: + return True, None + if message.validation_status == MessageValidationStatus.NEEDS_REVIEW and include_needs_review: + return True, None + return False, f"Validation status is {message.validation_status.value}" + + +def run_mock_campaign_send( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + send: bool = False, + include_warnings: bool = True, + include_needs_review: bool = False, + append_sent: bool = True, + clear_mailbox: bool = False, + check_files: bool = False, +) -> dict[str, Any]: + """Validate, build and optionally mock-send a version without mutating it. + + This is a dev/test route. It does not change campaign/version status, does + not queue real jobs and does not use the configured SMTP/IMAP servers. It + records mock SMTP deliveries and mock IMAP appends in the integrated mock + mailbox only when send=True. + """ + + campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none() + if not campaign: + raise MockCampaignSendError("Campaign not found or not accessible") + wanted_version_id = version_id or campaign.current_version_id + if not wanted_version_id: + raise MockCampaignSendError("Campaign has no current version") + version = session.get(CampaignVersion, wanted_version_id) + 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() + + with prepared_campaign_snapshot( + session, + tenant_id=tenant_id, + campaign_id=campaign.id, + raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, + include_bytes=True, + prefix="multimailer-mock-send-", + ) as prepared: + prepared_raw = load_campaign_json(prepared.path) + config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id) + 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) + + send_results: list[dict[str, Any]] = [] + sent_count = 0 + failed_count = 0 + skipped_count = 0 + imap_appended_count = 0 + imap_failed_count = 0 + + for built in build_result.built_messages: + draft = built.draft + can_send, skip_reason = _can_mock_send(draft, include_warnings=include_warnings, include_needs_review=include_needs_review) + row: dict[str, Any] = { + "entry_index": draft.entry_index, + "entry_id": draft.entry_id, + "subject": draft.subject, + "validation_status": draft.validation_status.value, + "build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status), + "to": _message_addresses_payload(draft.to), + "attachments": _attachment_payloads(draft), + "issues": _issue_payloads(draft), + } + + if not can_send or built.mime is None: + skipped_count += 1 + row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"}) + send_results.append(row) + continue + + recipients = _recipient_emails(draft) + envelope_from = _envelope_from(draft) + if not recipients: + skipped_count += 1 + row.update({"status": "skipped", "message": "No envelope recipients"}) + send_results.append(row) + continue + + if not send: + row.update({"status": "ready", "message": f"Would send to {len(recipients)} recipient(s)", "envelope_from": envelope_from, "envelope_recipients": recipients}) + send_results.append(row) + continue + + try: + if 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") + sent_count += 1 + row.update({ + "status": "sent", + "message": f"Mock SMTP captured as {smtp_record.id}", + "smtp_message_id": smtp_record.id, + "envelope_from": envelope_from, + "envelope_recipients": accepted, + "refused_recipients": rejected, + }) + + if append_sent: + try: + if 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_appended_count += 1 + row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder}) + except Exception as exc: + imap_failed_count += 1 + row.update({"imap_status": "failed", "imap_error": str(exc)}) + + except Exception as exc: + failed_count += 1 + row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients}) + + send_results.append(row) + + validation_json = validation_report.model_dump(mode="json") + validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count}) + build_report = build_result.report + build_json = build_report.model_dump(mode="json") + build_json.update({ + "built_count": build_report.built_count, + "queueable_count": build_report.queueable_count, + "needs_review_count": build_report.needs_review_count, + "blocked_count": build_report.blocked_count, + "warning_count": build_report.warning_count, + "ready_count": build_report.ready_count, + "messages": [_message_payload(message) for message in build_report.messages], + }) + + attempted_count = sum(1 for row in send_results if row.get("status") in {"sent", "failed"}) + return { + "campaign_id": campaign.id, + "version_id": version.id, + "version_number": version.version_number, + "send_requested": send, + "include_warnings": include_warnings, + "include_needs_review": include_needs_review, + "append_sent": append_sent, + "steps": [ + {"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json}, + {"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}}, + {"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if failed_count == 0 else "needs_review"), "summary": {"attempted": attempted_count, "sent": sent_count, "failed": failed_count, "skipped": skipped_count}}, + {"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if imap_failed_count == 0 else "needs_review"), "summary": {"appended": imap_appended_count, "failed": imap_failed_count}}, + ], + "validation": validation_json, + "build": build_json, + "send": { + "attempted_count": attempted_count, + "sent_count": sent_count, + "failed_count": failed_count, + "skipped_count": skipped_count, + "imap_appended_count": imap_appended_count, + "imap_failed_count": imap_failed_count, + "results": send_results, + }, + "mailbox": {"messages": list_records(limit=200)}, + } diff --git a/src/govoplan_campaign/backend/domain/__init__.py b/src/govoplan_campaign/backend/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/domain/campaign.py b/src/govoplan_campaign/backend/domain/campaign.py new file mode 100644 index 0000000..f26f833 --- /dev/null +++ b/src/govoplan_campaign/backend/domain/campaign.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path + +from .fields import Field, FieldConfiguration, FieldContents +from .recipients import Recipient, RecipientList +from .template import MailTemplate + + +class TransportSecurity(StrEnum): + PLAIN = "plain" + TLS = "tls" + STARTTLS = "starttls" + + @property + def standard_port(self) -> int: + return 465 if self == TransportSecurity.TLS else 587 + + +@dataclass +class MailServerSettings: + server: str = "" + port: int | None = None + username: str = "" + password: str = "" + transport_security: TransportSecurity = TransportSecurity.PLAIN + + def use_plain(self) -> "MailServerSettings": + self.transport_security = TransportSecurity.PLAIN + self.port = self.port or self.transport_security.standard_port + return self + + def use_tls(self) -> "MailServerSettings": + self.transport_security = TransportSecurity.TLS + self.port = self.port or self.transport_security.standard_port + return self + + def use_starttls(self) -> "MailServerSettings": + self.transport_security = TransportSecurity.STARTTLS + self.port = self.port or self.transport_security.standard_port + return self + + def resolved_port(self) -> int: + return self.port or self.transport_security.standard_port + + +@dataclass(frozen=True, slots=True) +class MailAttachmentConfig: + base_dir: Path + file_filter: str = "*" + include_subdirs: bool = False + + +@dataclass +class MailEntry: + field_config: FieldConfiguration + is_active: bool = True + from_recipient: Recipient | None = None + to: RecipientList = field(default_factory=RecipientList) + cc: RecipientList = field(default_factory=RecipientList) + bcc: RecipientList = field(default_factory=RecipientList) + combine_to: bool = True + combine_cc: bool = True + combine_bcc: bool = True + attachment_configs: list[MailAttachmentConfig] = field(default_factory=list) + combine_attachments: bool = True + field_contents: FieldContents = field(init=False) + + def __post_init__(self) -> None: + self.field_contents = FieldContents(self.field_config) + + def add_to(self, recipient: Recipient) -> "MailEntry": + self.to.add_recipient(recipient) + return self + + def add_cc(self, recipient: Recipient) -> "MailEntry": + self.cc.add_recipient(recipient) + return self + + def add_bcc(self, recipient: Recipient) -> "MailEntry": + self.bcc.add_recipient(recipient) + return self + + def no_combine_to(self) -> "MailEntry": + self.combine_to = False + return self + + def combine_to_recipients(self) -> "MailEntry": + self.combine_to = True + return self + + def no_combine_attachments(self) -> "MailEntry": + self.combine_attachments = False + return self + + def combine_attachments_with_global(self) -> "MailEntry": + self.combine_attachments = True + return self + + def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry": + self.attachment_configs.append(config) + return self + + def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry": + if not self.field_contents.set_field_content_for_name(name, value): + raise KeyError(f"unknown field: {name}") + return self + + def get_field_content_from_name(self, name: str) -> Field: + return self.field_contents.get_field_content_from_name(name) + + +@dataclass +class MailCampaign: + mail_server_settings: MailServerSettings | None = None + global_from: Recipient | None = None + global_to: RecipientList = field(default_factory=RecipientList) + global_cc: RecipientList = field(default_factory=RecipientList) + global_bcc: RecipientList = field(default_factory=RecipientList) + individual_from: bool = False + individual_to: bool = False + individual_cc: bool = False + individual_bcc: bool = False + base_attachment_path: Path = Path(".") + global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list) + individual_attachments: bool = False + send_without_attachments: bool = True + field_config: FieldConfiguration = field(default_factory=FieldConfiguration) + field_contents: FieldContents = field(init=False) + subject_template: MailTemplate = field(default_factory=MailTemplate) + mail_template: MailTemplate = field(default_factory=MailTemplate) + mail_entries: list[MailEntry] = field(default_factory=list) + + def __post_init__(self) -> None: + self.field_contents = FieldContents(self.field_config) + + @classmethod + def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign": + return cls(mail_server_settings=settings) + + def add_field(self, name: str, field_type) -> "MailCampaign": + from .fields import FieldDescription + self.field_config.add_field_at_end(FieldDescription(name, field_type)) + self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type] + for entry in self.mail_entries: + entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type] + return self + + def set_from(self, recipient: Recipient) -> "MailCampaign": + self.global_from = recipient + return self + + def add_to(self, recipient: Recipient) -> "MailCampaign": + self.global_to.add_recipient(recipient) + return self + + def allow_individual_to(self) -> "MailCampaign": + self.individual_to = True + return self + + def disallow_individual_to(self) -> "MailCampaign": + self.individual_to = False + return self + + def allow_individual_attachments(self) -> "MailCampaign": + self.individual_attachments = True + return self + + def disallow_individual_attachments(self) -> "MailCampaign": + self.individual_attachments = False + return self + + def dont_send_without_attachments(self) -> "MailCampaign": + self.send_without_attachments = False + return self + + def send_without_attachments_allowed(self) -> "MailCampaign": + self.send_without_attachments = True + return self + + def add_new_mail_entry(self) -> MailEntry: + entry = MailEntry(self.field_config) + self.mail_entries.append(entry) + return entry + + def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign": + if not self.field_contents.set_field_content_for_name(name, value): + raise KeyError(f"unknown field: {name}") + return self + + def get_field_content_from_name(self, name: str) -> Field: + return self.field_contents.get_field_content_from_name(name) + + def all_recipients_for(self, entry: MailEntry) -> list[Recipient]: + recipients: list[Recipient] = [] + if not self.individual_to or entry.combine_to: + recipients.extend(self.global_to.recipients) + if not self.individual_cc or entry.combine_cc: + recipients.extend(self.global_cc.recipients) + if not self.individual_bcc or entry.combine_bcc: + recipients.extend(self.global_bcc.recipients) + if self.individual_to: + recipients.extend(entry.to.recipients) + if self.individual_cc: + recipients.extend(entry.cc.recipients) + if self.individual_bcc: + recipients.extend(entry.bcc.recipients) + return recipients diff --git a/src/govoplan_campaign/backend/domain/fields.py b/src/govoplan_campaign/backend/domain/fields.py new file mode 100644 index 0000000..5ed29fb --- /dev/null +++ b/src/govoplan_campaign/backend/domain/fields.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from enum import StrEnum +from typing import Any + + +class FieldType(StrEnum): + STRING = "string" + INTEGER = "integer" + DOUBLE = "double" + DATE = "date" + PASSWORD = "password" + + +@dataclass(slots=True) +class FieldDescription: + name: str + type: FieldType = FieldType.STRING + can_override: bool = True + + +@dataclass(slots=True) +class Field: + content: Any + + @classmethod + def with_content(cls, content: Any) -> "Field": + if content is None: + raise ValueError("content must not be None") + return cls(content=content) + + @property + def type(self) -> FieldType: + if isinstance(self.content, bool): + return FieldType.STRING + if isinstance(self.content, int): + return FieldType.INTEGER + if isinstance(self.content, float): + return FieldType.DOUBLE + if isinstance(self.content, (date, datetime)): + return FieldType.DATE + if isinstance(self.content, (bytes, bytearray)): + return FieldType.PASSWORD + return FieldType.STRING + + def as_string(self) -> str: + if isinstance(self.content, (bytes, bytearray)): + return self.content.decode("utf-8") + if isinstance(self.content, (date, datetime)): + return self.content.isoformat() + return str(self.content) + + +@dataclass +class FieldConfiguration: + fields: list[FieldDescription] = field(default_factory=list) + + def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration": + return self.add_field_at_position(len(self.fields), field_description) + + def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration": + return self.add_field_at_position(0, field_description) + + def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration": + if self.has_field(field_description.name): + raise ValueError(f"field already exists: {field_description.name}") + position = max(0, min(position, len(self.fields))) + self.fields.insert(position, field_description) + return self + + def has_field(self, name: str) -> bool: + return any(f.name == name for f in self.fields) + + def get_field_description(self, name: str) -> FieldDescription | None: + return next((f for f in self.fields if f.name == name), None) + + def get_field_names(self) -> list[str]: + return [f.name for f in self.fields] + + +@dataclass +class FieldContents: + field_config: FieldConfiguration + field_map: dict[str, Field] = field(default_factory=dict) + + def __post_init__(self) -> None: + for field_description in self.field_config.fields: + self.ensure_field(field_description) + + def ensure_field(self, field_description: FieldDescription) -> None: + if field_description.name in self.field_map: + return + match field_description.type: + case FieldType.INTEGER: + value = 0 + case FieldType.DOUBLE: + value = 0.0 + case FieldType.DATE: + value = date.today() + case FieldType.PASSWORD: + value = b"" + case _: + value = "" + self.field_map[field_description.name] = Field.with_content(value) + + def get_field_content_from_name(self, name: str) -> Field: + try: + return self.field_map[name] + except KeyError as exc: + raise KeyError(f"unknown field: {name}") from exc + + def set_field_content_for_name(self, name: str, value: Field | Any) -> bool: + if name not in self.field_map: + return False + if not isinstance(value, Field): + value = Field.with_content(value) + expected = self.field_map[name].type + if expected != value.type and expected != FieldType.PASSWORD: + raise TypeError(f"field {name!r} expects {expected}, got {value.type}") + self.field_map[name] = value + return True + + def as_value_map(self, prefix: str) -> dict[str, str]: + return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()} diff --git a/src/govoplan_campaign/backend/domain/queue.py b/src/govoplan_campaign/backend/domain/queue.py new file mode 100644 index 0000000..aad26fe --- /dev/null +++ b/src/govoplan_campaign/backend/domain/queue.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from email.message import EmailMessage +from typing import Iterator + + +@dataclass +class MailQueue: + messages: list[EmailMessage] = field(default_factory=list) + + def add_mail(self, message: EmailMessage) -> None: + self.messages.append(message) + + def remove_mail(self, message: EmailMessage) -> bool: + if message in self.messages: + self.messages.remove(message) + return True + return False + + @property + def mail_count(self) -> int: + return len(self.messages) + + def is_empty(self) -> bool: + return not self.messages + + def __iter__(self) -> Iterator[EmailMessage]: + return iter(self.messages) diff --git a/src/govoplan_campaign/backend/domain/recipients.py b/src/govoplan_campaign/backend/domain/recipients.py new file mode 100644 index 0000000..790b00b --- /dev/null +++ b/src/govoplan_campaign/backend/domain/recipients.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from email.utils import formataddr +from enum import StrEnum + + +class RecipientType(StrEnum): + TO = "to" + CC = "cc" + BCC = "bcc" + + +@dataclass(frozen=True, slots=True) +class Recipient: + address: str + name: str | None = None + type: RecipientType = RecipientType.TO + + def formatted(self) -> str: + return formataddr((self.name or self.address, self.address)) + + +@dataclass +class RecipientList: + recipients: list[Recipient] = field(default_factory=list) + + def add_recipient(self, recipient: Recipient) -> "RecipientList": + if recipient not in self.recipients: + self.recipients.append(recipient) + return self + + def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList": + for recipient in recipients: + self.add_recipient(recipient) + return self + + def clear_all_recipients(self) -> "RecipientList": + self.recipients.clear() + return self + + def by_type(self, recipient_type: RecipientType) -> list[Recipient]: + return [r for r in self.recipients if r.type == recipient_type] diff --git a/src/govoplan_campaign/backend/domain/template.py b/src/govoplan_campaign/backend/domain/template.py new file mode 100644 index 0000000..3055dd0 --- /dev/null +++ b/src/govoplan_campaign/backend/domain/template.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +_FIELD_PATTERN = re.compile(r"(? "MailTemplate": + self.template_string = template + return self + + def get_used_fields(self) -> set[str]: + return set(_FIELD_PATTERN.findall(self.template_string)) + + def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1) + if key in values: + return values[key] + return match.group(0) if keep_missing else "" + + rendered = _FIELD_PATTERN.sub(replace, self.template_string) + return rendered.replace(r"\${", "${").replace(r"\}", "}") diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py new file mode 100644 index 0000000..a6b4e41 --- /dev/null +++ b/src/govoplan_campaign/backend/manifest.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate +from govoplan_core.db.base import Base +from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata + + +def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category=category, + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +PERMISSIONS = ( + _permission("campaigns:campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"), + _permission("campaigns:campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"), + _permission("campaigns:campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"), + _permission("campaigns:campaign:copy", "Copy campaigns", "Create campaigns or working versions from existing campaigns.", "Campaigns"), + _permission("campaigns:campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"), + _permission("campaigns:campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"), + _permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"), + _permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"), + _permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"), + _permission("campaigns:campaign:review", "Approve campaign review", "Approve or reject built messages and review conditions.", "Campaigns"), + _permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"), + _permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"), + _permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"), + _permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"), + _permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"), + _permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"), + _permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"), + _permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"), + _permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"), + _permission("campaigns:recipient:export", "Export recipients", "Export recipient data and reports.", "Recipients"), + _permission("campaigns:report:read", "View reports", "View campaign delivery reports and aggregate outcomes.", "Reports"), + _permission("campaigns:report:export", "Export reports", "Download detailed campaign reports.", "Reports"), + _permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="campaign_manager", + name="Campaign manager", + description="Prepare, validate and build campaigns without approving real delivery.", + permissions=( + "campaigns:campaign:read", + "campaigns:campaign:create", + "campaigns:campaign:update", + "campaigns:campaign:copy", + "campaigns:campaign:validate", + "campaigns:campaign:build", + "campaigns:recipient:read", + "campaigns:recipient:write", + "campaigns:recipient:import", + "campaigns:report:read", + ), + ), + RoleTemplate( + slug="campaign_reviewer", + name="Campaign reviewer", + description="Inspect and approve prepared campaign messages.", + permissions=( + "campaigns:campaign:read", + "campaigns:campaign:validate", + "campaigns:campaign:review", + "campaigns:recipient:read", + "campaigns:report:read", + ), + ), + RoleTemplate( + slug="campaign_sender", + name="Campaign sender", + description="Queue, test, control, send and reconcile prepared campaigns.", + permissions=( + "campaigns:campaign:read", + "campaigns:campaign:send_test", + "campaigns:campaign:queue", + "campaigns:campaign:control", + "campaigns:campaign:send", + "campaigns:campaign:retry", + "campaigns:campaign:reconcile", + "campaigns:recipient:read", + "campaigns:report:read", + "campaigns:report:send", + ), + ), +) + + +def _campaigns_router(context: ModuleContext): + del context + from govoplan_campaign.backend.router import router + + return router + + +manifest = ModuleManifest( + id="campaigns", + name="Campaigns", + version="1.0.0", + dependencies=("access", "files", "mail"), + optional_dependencies=(), + permissions=PERMISSIONS, + route_factory=_campaigns_router, + role_templates=ROLE_TEMPLATES, + nav_items=( + NavItem(path="/campaigns", label="Campaigns", icon="mail-check", required_any=("campaigns:campaign:read",), order=20), + NavItem( + path="/operator", + label="Operator Queue", + icon="activity", + required_any=( + "campaigns:campaign:queue", + "campaigns:campaign:retry", + "campaigns:campaign:reconcile", + "campaigns:campaign:control", + "campaigns:campaign:send", + ), + order=30, + ), + NavItem(path="/reports", label="Reports", icon="file-text", required_any=("campaigns:report:read",), order=70), + ), + frontend=FrontendModule( + module_id="campaigns", + package_name="@govoplan/campaign-webui", + nav_items=( + NavItem(path="/campaigns", label="Campaigns", icon="mail-check", required_any=("campaigns:campaign:read",), order=20), + NavItem( + path="/operator", + label="Operator Queue", + icon="activity", + required_any=( + "campaigns:campaign:queue", + "campaigns:campaign:retry", + "campaigns:campaign:reconcile", + "campaigns:campaign:control", + "campaigns:campaign:send", + ), + order=30, + ), + NavItem(path="/reports", label="Reports", icon="file-text", required_any=("campaigns:report:read",), order=70), + NavItem(path="/address-book", label="Address Book", icon="users", order=80), + NavItem(path="/templates", label="Templates", icon="form", order=90), + ), + ), + migration_spec=MigrationSpec( + module_id="campaigns", + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest + diff --git a/src/govoplan_campaign/backend/messages/__init__.py b/src/govoplan_campaign/backend/messages/__init__.py new file mode 100644 index 0000000..dcdc016 --- /dev/null +++ b/src/govoplan_campaign/backend/messages/__init__.py @@ -0,0 +1,12 @@ +"""Message building and review helpers.""" + +from .builder import build_campaign_messages +from .models import CampaignBuildReport, MessageDraft, MessageIssue, MessageValidationStatus + +__all__ = [ + "build_campaign_messages", + "CampaignBuildReport", + "MessageDraft", + "MessageIssue", + "MessageValidationStatus", +] diff --git a/src/govoplan_campaign/backend/messages/builder.py b/src/govoplan_campaign/backend/messages/builder.py new file mode 100644 index 0000000..85605d7 --- /dev/null +++ b/src/govoplan_campaign/backend/messages/builder.py @@ -0,0 +1,618 @@ +from __future__ import annotations + +import mimetypes +import re +import tempfile +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import make_msgid, formatdate +from pathlib import Path +from typing import Any, Iterable + +from govoplan_campaign.backend.attachments.resolver import ( + AttachmentMatchStatus, + EntryAttachmentResolution, + MessageAttachmentStatus, + ResolvedAttachment, + resolve_entry_attachments, +) +from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient +from govoplan_campaign.backend.campaign.entries import load_campaign_entries +from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides +from govoplan_campaign.backend.campaign.models import ( + Behavior, + BuildStatus, + CampaignConfig, + EntryConfig, + MissingAddressBehavior, + RecipientConfig, + SendStatus, + ZipArchiveConfig, + ZipPasswordMode, + ZipPasswordScope, +) +from govoplan_campaign.backend.campaign.template_values import build_template_values +from govoplan_campaign.backend.services.zip_service import create_zip_archive + +from .models import ( + CampaignBuildReport, + ImapStatus, + MessageAddress, + MessageAttachmentSummary, + MessageDraft, + MessageIssue, + MessageValidationStatus, +) + +_DOLLAR_FIELD_PATTERN = re.compile(r"(? str: + key = raw.strip() + if key.startswith("fields."): + key = key.removeprefix("fields.") + elif key.startswith("local."): + key = "local::" + key.removeprefix("local.") + elif key.startswith("global."): + key = "global::" + key.removeprefix("global.") + + if key.startswith("local::") or key.startswith("global::"): + return key + if key.startswith("local:"): + return "local::" + key.removeprefix("local:") + if key.startswith("global:"): + return "global::" + key.removeprefix("global:") + return key + + +@dataclass(slots=True) +class BuiltMessage: + draft: MessageDraft + mime: EmailMessage | None + + +@dataclass(slots=True) +class CampaignBuildResult: + report: CampaignBuildReport + built_messages: list[BuiltMessage] + + +def _resolve(campaign_file: str | Path, raw_path: str) -> Path: + campaign_path = Path(campaign_file).resolve() + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_path.parent / path).resolve() + + +def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str = "utf-8") -> str | None: + if not raw_path: + return None + path = _resolve(campaign_file, raw_path) + return path.read_text(encoding=encoding) + + +def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str: + def replace(match: re.Match[str]) -> str: + key = _normalize_template_key(match.group(1)) + if key in values: + value = values[key] + return "" if value is None else str(value) + return match.group(0) if keep_missing else "" + + rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template) + rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered) + return rendered.replace(r"\${", "${").replace(r"\}", "}") + + +def _find_unresolved_placeholders(text: str | None) -> set[str]: + if not text: + return set() + return { + _normalize_template_key(match.group(1)) + for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN) + for match in pattern.finditer(text) + } + + +def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None: + if recipient is None: + return None + return MessageAddress(email=recipient.email, name=recipient.name) + + +def _message_addresses(recipients: Iterable[RecipientConfig]) -> list[MessageAddress]: + return [MessageAddress(email=recipient.email, name=recipient.name) for recipient in recipients] + + +def _format_recipient(recipient: RecipientConfig) -> str: + return formatted_recipient(recipient) + + +def _format_recipient_header(recipients: Iterable[RecipientConfig]) -> str: + return ", ".join(_format_recipient(recipient) for recipient in recipients) + + +def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> tuple[str, str | None, str | None]: + template = config.template + if template.source: + subject = _read_text(campaign_file, template.source.subject_path, template.source.encoding) + text = _read_text(campaign_file, template.source.text_path, template.source.encoding) + html = _read_text(campaign_file, template.source.html_path, template.source.encoding) + return subject or "", text, html + return template.subject or "", template.text, template.html + + +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) + + +def _apply_behavior(current: MessageValidationStatus, behavior: str) -> MessageValidationStatus: + if behavior == Behavior.BLOCK.value: + return MessageValidationStatus.BLOCKED + if behavior == Behavior.DROP.value: + return MessageValidationStatus.EXCLUDED + if behavior == Behavior.ASK.value: + if current not in {MessageValidationStatus.BLOCKED, MessageValidationStatus.EXCLUDED}: + return MessageValidationStatus.NEEDS_REVIEW + if behavior == Behavior.WARN.value: + if current == MessageValidationStatus.READY: + return MessageValidationStatus.WARNING + # continue leaves status as-is + return current + + +def _validation_status_from_attachment_status(status: MessageAttachmentStatus) -> MessageValidationStatus: + return MessageValidationStatus(status.value) + + +def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[MessageAttachmentSummary]: + return [ + MessageAttachmentSummary( + attachment_id=attachment.attachment_id, + label=attachment.label, + status=attachment.status.value, + behavior=attachment.behavior.value if attachment.behavior else None, + required=attachment.required, + allow_multiple=attachment.allow_multiple, + zip_enabled=attachment.zip_enabled, + zip_mode=attachment.zip_mode.value, + zip_archive_id=attachment.zip_archive_id, + zip_filename=attachment.zip_filename, + base_path_name=attachment.base_path_name, + base_path=attachment.base_path, + file_filter=attachment.file_filter, + directory=attachment.directory, + matches=attachment.matches, + ) + for attachment in resolution.attachments + ] + + +def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolution) -> list[MessageIssue]: + return [ + MessageIssue( + severity=issue.severity.value, + code=issue.code, + message=issue.message, + behavior=issue.behavior.value if issue.behavior else None, + source="attachments", + ) + for issue in resolution.issues + ] + + +def _safe_filename(value: str | None, fallback: str) -> str: + raw = value or fallback + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._") + return safe or fallback + + +def _attachment_bytes(path: Path) -> tuple[bytes, str, str]: + data = path.read_bytes() + mime_type, _ = mimetypes.guess_type(str(path)) + if not mime_type: + return data, "application", "octet-stream" + maintype, subtype = mime_type.split("/", 1) + return data, maintype, subtype + + +class ZipBuildError(RuntimeError): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +def _zip_password(archive: ZipArchiveConfig, values: dict[str, Any]) -> str: + if not archive.password_enabled: + return "" + + # Preserve support for campaigns written by the original single-archive + # implementation while all new archives use field + scope. + mode = archive.password_mode + if mode == ZipPasswordMode.DIRECT: + password = archive.password or "" + if not password: + raise ZipBuildError("zip_password_missing", f"ZIP archive {archive.name!r} has no fixed password") + return password + if mode == ZipPasswordMode.TEMPLATE: + password = _render_template(archive.password_template or "", values, keep_missing=False) + if not password: + raise ZipBuildError("zip_password_value_missing", f"ZIP archive {archive.name!r} produced an empty password") + return password + + field_name = (archive.password_field or "").strip() + if not field_name: + raise ZipBuildError("zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no password field") + scope = archive.password_scope.value if isinstance(archive.password_scope, ZipPasswordScope) else str(archive.password_scope) + value = values.get(f"{scope}::{field_name}") + # Legacy field-based configurations did not declare a scope. + if value in (None, "") and mode == ZipPasswordMode.FIELD: + value = values.get(field_name) + password = "" if value is None else str(value) + if not password: + qualifier = "campaign-global" if scope == ZipPasswordScope.GLOBAL.value else "recipient" + raise ZipBuildError( + "zip_password_value_missing", + f"The {qualifier} ZIP password field {field_name!r} is empty for archive {archive.name!r}", + ) + return password + + +def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_index: int) -> str: + rendered = _render_template(archive.name or "attachments.zip", values, keep_missing=False) + filename = _safe_filename(rendered, f"entry-{entry_index:04d}-attachments.zip") + 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) + counter = 2 + while candidate.casefold() in used: + candidate = f"{path.stem} ({counter}){path.suffix}" + counter += 1 + used.add(candidate.casefold()) + return candidate + + +def _attach_files( + *, + message: EmailMessage, + config: CampaignConfig, + entry: EntryConfig, + entry_index: int, + resolution: EntryAttachmentResolution, + values: dict[str, Any], + work_dir: Path, +) -> int: + attached_count = 0 + archive_members: dict[str, list[Path]] = {} + archive_attachments: dict[str, list[ResolvedAttachment]] = {} + used_message_filenames: set[str] = set() + + 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) + archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment) + continue + for path in match_paths: + filename = _unique_attachment_filename(path.name, used_message_filenames) + 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, [])) + if not members: + continue + filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames) + password = _zip_password(archive, values) + archive_path = create_zip_archive( + work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename, + members, + password, + ) + data, maintype, subtype = _attachment_bytes(archive_path) + message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + attached_count += 1 + for attachment in archive_attachments.get(archive.id, []): + attachment.zip_filename = filename + + return attached_count + +def _imap_initial_status(config: CampaignConfig) -> ImapStatus: + if config.delivery.imap_append_sent.enabled: + return ImapStatus.PENDING + return ImapStatus.NOT_REQUESTED + + +def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entry_index: int) -> tuple[str, int]: + output_dir.mkdir(parents=True, exist_ok=True) + filename = _safe_filename(entry.id, f"entry-{entry_index:04d}") + ".eml" + path = output_dir / filename + path.write_bytes(bytes(message)) + return str(path), path.stat().st_size + + +def build_entry_message( + *, + config: CampaignConfig, + campaign_file: str | Path, + entry: EntryConfig, + entry_index: int, + output_dir: Path | None = None, + write_eml: bool = False, + work_dir: Path | None = None, +) -> BuiltMessage: + resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index) + effective_addresses = effective_address_lists(config, entry) + senders = effective_addresses["from"] + sender = senders[0] if senders else None + recipients = {key: value for key, value in effective_addresses.items() if key != "from"} + issues = _message_issues_from_attachment_resolution(resolution) + validation_status = _validation_status_from_attachment_status(resolution.status) + + ignored_field_overrides = ignored_entry_field_overrides(config, entry) + if ignored_field_overrides: + issues.append( + MessageIssue( + severity="warning", + code="field_override_not_allowed", + message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides), + behavior="warn", + source="fields", + ) + ) + if validation_status == MessageValidationStatus.READY: + validation_status = MessageValidationStatus.WARNING + + if not entry.active: + draft = MessageDraft( + entry_index=entry_index, + entry_id=entry.id, + active=False, + build_status=BuildStatus.BUILD_FAILED, + validation_status=MessageValidationStatus.INACTIVE, + send_status=SendStatus.DRAFT, + imap_status=ImapStatus.SKIPPED, + from_=_message_address(sender), + from_all=_message_addresses(senders), + to=_message_addresses(recipients["to"]), + cc=_message_addresses(recipients["cc"]), + bcc=_message_addresses(recipients["bcc"]), + reply_to=_message_addresses(recipients["reply_to"]), + bounce_to=_message_addresses(recipients["bounce_to"]), + disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]), + attachments=_attachment_summaries(resolution), + issues=[MessageIssue(severity="info", code="inactive_entry", message="Entry is inactive", behavior=config.validation_policy.inactive_entry.value, source="entry")], + ) + return BuiltMessage(draft=draft, mime=None) + + if not recipients["to"]: + behavior = config.validation_policy.missing_email.value + issues.append(_issue_from_behavior(code="missing_email", message="No effective To recipient is configured", behavior=behavior, source="recipients")) + validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED + + subject_template, text_template, html_template = _load_template_parts(config, campaign_file) + 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) + ) + if unresolved: + behavior = config.validation_policy.template_error.value + issues.append( + _issue_from_behavior( + code="template_error", + message="Unresolved template placeholder(s): " + ", ".join(unresolved), + behavior=behavior, + source="template", + ) + ) + validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED + + message = EmailMessage() + try: + message["Date"] = formatdate(localtime=True) + message["Message-ID"] = make_msgid() + if senders: + message["From"] = _format_recipient_header(senders) + if len(senders) > 1: + # RFC 5322 requires a singular Sender when From contains more + # than one mailbox. The first effective From address remains + # the SMTP envelope sender and compatibility primary value. + message["Sender"] = _format_recipient(senders[0]) + if recipients["to"]: + message["To"] = _format_recipient_header(recipients["to"]) + if recipients["cc"]: + message["Cc"] = _format_recipient_header(recipients["cc"]) + # Bcc deliberately remains envelope-only and is tracked in MessageDraft. + if recipients["reply_to"]: + message["Reply-To"] = _format_recipient_header(recipients["reply_to"]) + if recipients["disposition_notification_to"]: + message["Disposition-Notification-To"] = _format_recipient_header(recipients["disposition_notification_to"]) + # bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender. + message["Subject"] = subject + + if html_body is not None: + message.set_content(text_body or "") + message.add_alternative(html_body, subtype="html") + else: + message.set_content(text_body or "") + + if work_dir is None: + work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-")) + attachment_count = _attach_files( + message=message, + config=config, + entry=entry, + entry_index=entry_index, + resolution=resolution, + values=values, + work_dir=work_dir, + ) + build_status = BuildStatus.BUILT + except ZipBuildError as exc: + issues.append(MessageIssue(severity="error", code=exc.code, message=str(exc), behavior="block", source="attachments")) + validation_status = MessageValidationStatus.BLOCKED + build_status = BuildStatus.BUILD_FAILED + attachment_count = 0 + message = None # type: ignore[assignment] + except Exception as exc: + issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder")) + validation_status = MessageValidationStatus.BLOCKED + build_status = BuildStatus.BUILD_FAILED + attachment_count = 0 + message = None # type: ignore[assignment] + + eml_path: str | None = None + eml_size: int | None = None + if write_eml and output_dir is not None and message is not None: + eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index) + + draft = MessageDraft( + entry_index=entry_index, + entry_id=entry.id, + active=entry.active, + build_status=build_status, + validation_status=validation_status, + send_status=SendStatus.DRAFT, + imap_status=_imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED, + subject=subject, + from_=_message_address(sender), + from_all=_message_addresses(senders), + to=_message_addresses(recipients["to"]), + cc=_message_addresses(recipients["cc"]), + bcc=_message_addresses(recipients["bcc"]), + reply_to=_message_addresses(recipients["reply_to"]), + bounce_to=_message_addresses(recipients["bounce_to"]), + disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]), + attachment_count=attachment_count, + attachments=_attachment_summaries(resolution), + issues=issues, + eml_path=eml_path, + eml_size_bytes=eml_size, + ) + return BuiltMessage(draft=draft, mime=message) + + + +def _unsent_attachment_issues( + *, + config: CampaignConfig, + campaign_file: str | Path, + built_messages: list[BuiltMessage], +) -> list[MessageIssue]: + behavior = config.validation_policy.unsent_attachment_files.value + if behavior == Behavior.CONTINUE.value: + return [] + + matched_files = { + Path(match).resolve() + for built in built_messages + for attachment in built.draft.attachments + for match in attachment.matches + } + + issues: list[MessageIssue] = [] + for base_path in config.attachments.base_paths: + if not base_path.unsent_warning: + continue + 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()) + unsent = [path for path in all_files if path not in matched_files] + if not unsent: + continue + shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10]) + if len(unsent) > 10: + shown += f", … (+{len(unsent) - 10} more)" + issues.append( + _issue_from_behavior( + code="unsent_attachment_files", + message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}", + behavior=behavior, + source=f"attachments:{base_path.name}", + ) + ) + return issues + + +def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None: + if not issues: + return + for built in built_messages: + if not built.draft.active: + continue + built.draft.issues.extend(issues) + status = built.draft.validation_status + for issue in issues: + if issue.behavior: + status = _apply_behavior(status, issue.behavior) + built.draft.validation_status = status + +def build_campaign_messages( + config: CampaignConfig, + *, + campaign_file: str | Path, + output_dir: str | Path | None = None, + write_eml: bool = False, +) -> CampaignBuildResult: + campaign_path = Path(campaign_file).resolve() + entries = load_campaign_entries(config, campaign_file=campaign_path) + output_path = Path(output_dir).resolve() if output_dir is not None else None + + with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp: + work_dir = output_path or Path(tmp) + built_messages = [ + build_entry_message( + config=config, + campaign_file=campaign_path, + entry=entry, + entry_index=index, + output_dir=output_path, + write_eml=write_eml, + work_dir=work_dir, + ) + 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), + ) + + report = CampaignBuildReport( + campaign_id=config.campaign.id, + campaign_name=config.campaign.name, + campaign_file=str(campaign_path), + 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], + ) + return CampaignBuildResult(report=report, built_messages=built_messages) diff --git a/src/govoplan_campaign/backend/messages/models.py b/src/govoplan_campaign/backend/messages/models.py new file mode 100644 index 0000000..bec345a --- /dev/null +++ b/src/govoplan_campaign/backend/messages/models.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from govoplan_campaign.backend.campaign.models import BuildStatus, SendStatus + + +class MessageValidationStatus(StrEnum): + READY = "ready" + WARNING = "warning" + NEEDS_REVIEW = "needs_review" + BLOCKED = "blocked" + EXCLUDED = "excluded" + INACTIVE = "inactive" + + +class ImapStatus(StrEnum): + NOT_REQUESTED = "not_requested" + PENDING = "pending" + APPENDED = "appended" + FAILED = "failed" + SKIPPED = "skipped" + + +class MessageIssue(BaseModel): + model_config = ConfigDict(extra="forbid") + + severity: Literal["info", "warning", "error"] + code: str + message: str + behavior: str | None = None + source: str | None = None + + +class MessageAddress(BaseModel): + model_config = ConfigDict(extra="forbid") + + email: str + name: str | None = None + + +class MessageAttachmentSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + attachment_id: str | None = None + label: str | None = None + status: str + behavior: str | None = None + required: bool + allow_multiple: bool + zip_enabled: bool + zip_mode: str = "inherit" + zip_archive_id: str | None = None + zip_filename: str | None = None + base_path_name: str | None = None + base_path: str | None = None + file_filter: str + directory: str + matches: list[str] = Field(default_factory=list) + managed_matches: list[dict[str, object]] = Field(default_factory=list) + + +class MessageDraft(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + entry_index: int + entry_id: str | None = None + active: bool + + build_status: BuildStatus + validation_status: MessageValidationStatus + send_status: SendStatus + imap_status: ImapStatus + + subject: str | None = None + from_: MessageAddress | None = Field(default=None, alias="from") + from_all: list[MessageAddress] = Field(default_factory=list) + to: list[MessageAddress] = Field(default_factory=list) + cc: list[MessageAddress] = Field(default_factory=list) + bcc: list[MessageAddress] = Field(default_factory=list) + reply_to: list[MessageAddress] = Field(default_factory=list) + bounce_to: list[MessageAddress] = Field(default_factory=list) + disposition_notification_to: list[MessageAddress] = Field(default_factory=list) + + attachment_count: int = 0 + attachments: list[MessageAttachmentSummary] = Field(default_factory=list) + issues: list[MessageIssue] = Field(default_factory=list) + + eml_path: str | None = None + eml_size_bytes: int | None = None + + @property + def is_queueable(self) -> bool: + return self.active and self.build_status == BuildStatus.BUILT and self.validation_status in { + MessageValidationStatus.READY, + MessageValidationStatus.WARNING, + } + + +class CampaignBuildReport(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_id: str + campaign_name: str + campaign_file: str + entries_count: int + inactive_entries_count: int = 0 + messages: list[MessageDraft] = Field(default_factory=list) + + @property + def built_count(self) -> int: + return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILT) + + @property + def build_failed_count(self) -> int: + return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILD_FAILED) + + @property + def ready_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.READY) + + @property + def warning_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.WARNING) + + @property + def needs_review_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.NEEDS_REVIEW) + + @property + def blocked_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.BLOCKED) + + @property + def excluded_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.EXCLUDED) + + @property + def inactive_count(self) -> int: + return self.inactive_entries_count + + @property + def queueable_count(self) -> int: + return sum(1 for message in self.messages if message.is_queueable) diff --git a/src/govoplan_campaign/backend/migrations/__init__.py b/src/govoplan_campaign/backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/migrations/versions/__init__.py b/src/govoplan_campaign/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/persistence/__init__.py b/src/govoplan_campaign/backend/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/persistence/campaigns.py b/src/govoplan_campaign/backend/persistence/campaigns.py new file mode 100644 index 0000000..704311f --- /dev/null +++ b/src/govoplan_campaign/backend/persistence/campaigns.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +import hashlib +import json +from email import policy +from email.parser import BytesParser +from pathlib import Path +from typing import Any +import copy +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignStatus, + CampaignVersion, + CampaignVersionWorkflowState, + JobImapStatus, + JobQueueStatus, + JobSendStatus, + JobValidationStatus, +) +from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json, validate_against_schema +from govoplan_campaign.backend.campaign.validation import validate_campaign_config +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, +) + +RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime" +CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots" +BUILD_OUTPUT_DIR = RUNTIME_DIR / "generated_eml" + + +class CampaignPersistenceError(RuntimeError): + pass + + +def _ensure_dirs() -> None: + CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) + BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + + + +def load_campaign_config_from_json( + session: Session, + *, + tenant_id: str, + raw_json: dict[str, Any], + campaign_id: str | None = None, + owner_user_id: str | None = None, + owner_group_id: str | None = None, +) -> CampaignConfig: + materialized = materialize_campaign_mail_profile_config( + session, + tenant_id=tenant_id, + raw_json=raw_json, + campaign_id=campaign_id, + owner_user_id=owner_user_id, + owner_group_id=owner_group_id, + ) + validate_against_schema(materialized) + return CampaignConfig.model_validate(materialized) + + +def _write_campaign_snapshot(version: CampaignVersion) -> Path: + _ensure_dirs() + path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json" + path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def _next_version_number(session: Session, campaign_id: str) -> int: + current = session.query(func.max(CampaignVersion.version_number)).filter(CampaignVersion.campaign_id == campaign_id).scalar() + return int(current or 0) + 1 + + +def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | None: + if not value or base_path is None: + return value + path = Path(value).expanduser() + if path.is_absolute(): + return str(path) + return str((base_path / path).resolve()) + + +def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]: + """Return a DB/runtime-safe campaign JSON snapshot. + + The CLI naturally resolves relative paths against the campaign.json file. + Once the campaign is stored in the database, the JSON snapshot lives in + app/mailer/runtime/campaign_snapshots. To keep existing file-based + campaigns working, relative file paths are normalized to absolute paths at + import time when a source_base_path is known. + """ + base = Path(source_base_path).expanduser().resolve() if source_base_path else None + data = copy.deepcopy(raw_json) + + template_source = data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None + if isinstance(template_source, dict): + for key in ("subject_path", "text_path", "html_path"): + template_source[key] = _resolve_runtime_path(base, template_source.get(key)) + + entries_source = data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None + if isinstance(entries_source, dict): + entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path")) + + attachments = data.get("attachments") + if isinstance(attachments, dict): + attachments["base_path"] = _resolve_runtime_path(base, attachments.get("base_path")) or "." + + return data + + +def create_campaign_version_from_json( + session: Session, + *, + tenant_id: str, + user_id: str | None, + raw_json: dict[str, Any], + source_filename: str | None = None, + source_base_path: str | None = None, +) -> tuple[Campaign, CampaignVersion]: + if source_base_path is None and source_filename: + source_path = Path(source_filename).expanduser() + source_base_path = str(source_path.parent if source_path.suffix else source_path) + + runtime_json = normalize_campaign_paths(raw_json, source_base_path) + + config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id) + + campaign = ( + session.query(Campaign) + .filter(Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id) + .one_or_none() + ) + if campaign is None: + campaign = Campaign( + tenant_id=tenant_id, + created_by_user_id=user_id, + owner_user_id=user_id, + external_id=config.campaign.id, + name=config.campaign.name, + description=config.campaign.description, + status=CampaignStatus.DRAFT.value, + ) + session.add(campaign) + session.flush() + else: + current = session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None + if current and not _version_is_audit_safe_snapshot(current): + raise CampaignPersistenceError( + f"Campaign already has active working version #{current.version_number}. " + "Continue editing or unlock that version instead of importing a parallel draft." + ) + campaign.name = config.campaign.name + campaign.description = config.campaign.description + + version = CampaignVersion( + campaign_id=campaign.id, + version_number=_next_version_number(session, campaign.id), + raw_json=runtime_json, + schema_version=raw_json.get("version", "1.0"), + source_filename=source_filename, + source_base_path=source_base_path, + ) + session.add(version) + session.flush() + campaign.current_version_id = version.id + session.add(campaign) + _write_campaign_snapshot(version) + session.commit() + return campaign, version + + + + +def _version_user_lock_state(version: CampaignVersion) -> str | None: + state = getattr(version, "user_lock_state", None) + if state in {"temporary", "permanent"}: + return state + return "permanent" if version.published_at else None + + +def _version_is_user_locked(version: CampaignVersion) -> bool: + return _version_user_lock_state(version) is not None + + +def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool: + return _version_user_lock_state(version) == "permanent" or version.workflow_state in { + CampaignVersionWorkflowState.QUEUED.value, + CampaignVersionWorkflowState.SENDING.value, + CampaignVersionWorkflowState.COMPLETED.value, + CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value, + CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value, + CampaignVersionWorkflowState.FAILED.value, + CampaignVersionWorkflowState.CANCELLED.value, + CampaignVersionWorkflowState.ARCHIVED.value, + } + + +def _ensure_current_campaign_version(campaign: Campaign, version: CampaignVersion, *, action: str) -> None: + if campaign.current_version_id != version.id: + raise CampaignPersistenceError( + f"Historical campaign versions are read-only and cannot be used to {action}. " + "Open the current working version instead." + ) + + +def _version_is_validated_and_locked(version: CampaignVersion) -> bool: + validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {} + return bool(version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version)) + + +def _ensure_version_validated_and_locked(version: CampaignVersion) -> None: + state = _version_user_lock_state(version) + if state == "temporary": + raise CampaignPersistenceError("This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending.") + if state == "permanent": + raise CampaignPersistenceError("This version is permanently user-locked. Create an editable copy instead.") + if not _version_is_validated_and_locked(version): + raise CampaignPersistenceError("Campaign version must be validated and locked before building, queueing, dry-run or sending.") + +def load_version_config(session: Session, version_id: str): + version = session.get(CampaignVersion, version_id) + if not version: + raise CampaignPersistenceError(f"Campaign version not found: {version_id}") + campaign = session.get(Campaign, version.campaign_id) + if not campaign: + raise CampaignPersistenceError(f"Campaign not found for version: {version_id}") + path = _write_campaign_snapshot(version) + raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} + return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id) + + +def validate_campaign_version( + session: Session, + *, + tenant_id: str, + version_id: str, + check_files: bool = False, + user_id: str | None = None, + lock_on_success: bool = True, +) -> dict[str, Any]: + version, snapshot_path, config = load_version_config(session, version_id) + campaign = session.get(Campaign, version.campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + _ensure_current_campaign_version(campaign, version, action="validate") + if _version_is_user_locked(version) or version.workflow_state in { + CampaignVersionWorkflowState.QUEUED.value, + CampaignVersionWorkflowState.SENDING.value, + CampaignVersionWorkflowState.COMPLETED.value, + CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value, + CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value, + CampaignVersionWorkflowState.FAILED.value, + CampaignVersionWorkflowState.CANCELLED.value, + CampaignVersionWorkflowState.ARCHIVED.value, + }: + lock_label = "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final" + 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( + session, + tenant_id=tenant_id, + campaign_id=campaign.id, + raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, + include_bytes=False, + prefix="multimailer-managed-validate-", + ) as prepared: + managed_raw = load_campaign_json(prepared.path) + managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id) + report = validate_campaign_config(managed_config, campaign_file=prepared.path, check_files=True) + else: + report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=False) + report_json = report.model_dump(mode="json") + report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count}) + version.validation_summary = report_json + + # Replace version-level semantic issues from previous validations. + ( + session.query(CampaignIssue) + .filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None)) + .delete(synchronize_session=False) + ) + for issue in report.issues: + session.add( + CampaignIssue( + tenant_id=tenant_id, + campaign_id=campaign.id, + campaign_version_id=version.id, + severity=issue.severity.value, + code=issue.code, + message=issue.message, + source=issue.path, + ) + ) + + campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value + if report.ok: + version.workflow_state = CampaignVersionWorkflowState.APPROVED.value + version.is_complete = True + if lock_on_success and version.locked_at is None: + from datetime import UTC, datetime + + version.locked_at = datetime.now(UTC) + version.locked_by_user_id = user_id + else: + version.workflow_state = CampaignVersionWorkflowState.EDITING.value + session.add(version) + session.add(campaign) + session.commit() + return report_json + + +def _job_validation_status(value: str) -> str: + allowed = {item.value for item in JobValidationStatus} + return value if value in allowed else JobValidationStatus.NEEDS_REVIEW.value + + +def _eml_evidence(eml_path: str | None) -> tuple[str | None, str | None]: + if not eml_path: + return None, None + path = Path(eml_path) + if not path.exists(): + return None, None + payload = path.read_bytes() + message_id = BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID") + return hashlib.sha256(payload).hexdigest(), str(message_id) if message_id else None + + +def _job_from_message( + *, + tenant_id: str, + campaign_id: str, + version_id: str, + message: MessageDraft, +) -> CampaignJob: + recipient_email = message.to[0].email if message.to else None + eml_sha256, message_id_header = _eml_evidence(message.eml_path) + return CampaignJob( + tenant_id=tenant_id, + campaign_id=campaign_id, + campaign_version_id=version_id, + entry_index=message.entry_index, + entry_id=message.entry_id, + recipient_email=recipient_email, + subject=message.subject, + message_id_header=message_id_header, + eml_local_path=message.eml_path, + eml_size_bytes=message.eml_size_bytes, + eml_sha256=eml_sha256, + build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status), + validation_status=_job_validation_status(message.validation_status.value), + queue_status=JobQueueStatus.DRAFT.value, + send_status=JobSendStatus.NOT_QUEUED.value, + imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value, + resolved_recipients={ + "from": message.from_.model_dump(mode="json") if message.from_ else None, + "from_all": [item.model_dump(mode="json") for item in message.from_all], + "to": [item.model_dump(mode="json") for item in message.to], + "cc": [item.model_dump(mode="json") for item in message.cc], + "bcc": [item.model_dump(mode="json") for item in message.bcc], + "reply_to": [item.model_dump(mode="json") for item in message.reply_to], + "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], + 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, + ) + + +def build_campaign_version( + session: Session, + *, + tenant_id: str, + version_id: str, + write_eml: bool = True, +) -> dict[str, Any]: + version, snapshot_path, config = load_version_config(session, version_id) + campaign = session.get(Campaign, version.campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + _ensure_current_campaign_version(campaign, version, action="build") + if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value: + raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt") + validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {} + if not validation_summary.get("ok"): + raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built") + _ensure_version_validated_and_locked(version) + + output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id + with prepared_campaign_snapshot( + session, + tenant_id=tenant_id, + campaign_id=campaign.id, + raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, + include_bytes=True, + prefix="multimailer-managed-build-", + ) as prepared: + managed_raw = load_campaign_json(prepared.path) + managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id) + 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) + 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] + report_json["built_at"] = datetime.now(UTC).isoformat() + report_json["build_token"] = uuid4().hex + report_json.update({ + "built_count": result.report.built_count, + "build_failed_count": result.report.build_failed_count, + "ready_count": result.report.ready_count, + "warning_count": result.report.warning_count, + "needs_review_count": result.report.needs_review_count, + "blocked_count": result.report.blocked_count, + "excluded_count": result.report.excluded_count, + "inactive_count": result.report.inactive_count, + "queueable_count": result.report.queueable_count, + }) + version.build_summary = report_json + editor_state = copy.deepcopy(version.editor_state or {}) + editor_state.pop("review_send", None) + version.editor_state = editor_state + + # Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds. + session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False) + session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False) + session.flush() + + job_build_pairs: list[tuple[CampaignJob, MessageDraft]] = [] + for built in result.built_messages: + job = _job_from_message( + tenant_id=tenant_id, + campaign_id=campaign.id, + version_id=version.id, + message=built.draft, + ) + session.add(job) + job_build_pairs.append((job, built.draft)) + + # Assign all job IDs in one round-trip, then persist exact attachment use + # records in bulk. This avoids one flush plus several metadata queries per + # recipient for large campaigns. + session.flush() + record_campaign_attachment_uses_for_jobs( + session, + [job for job, _message in job_build_pairs], + stage="built", + ) + if not managed_config.server.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, + delivery=managed_config.delivery, + jobs=[job for job, _message in job_build_pairs], + build_summary=report_json, + ) + version.execution_snapshot = execution_snapshot + version.execution_snapshot_hash = execution_snapshot_hash + version.execution_snapshot_at = datetime.now(UTC) + for job, message in job_build_pairs: + for issue in message.issues: + session.add( + CampaignIssue( + tenant_id=tenant_id, + campaign_id=campaign.id, + campaign_version_id=version.id, + job_id=job.id, + severity=issue.severity, + code=issue.code, + message=issue.message, + source=issue.source, + behavior=issue.behavior, + ) + ) + + if result.report.needs_review_count or result.report.blocked_count: + campaign.status = CampaignStatus.NEEDS_REVIEW.value + version.workflow_state = CampaignVersionWorkflowState.APPROVED.value + elif result.report.queueable_count > 0: + campaign.status = CampaignStatus.READY_TO_QUEUE.value + version.workflow_state = CampaignVersionWorkflowState.BUILT.value + else: + campaign.status = CampaignStatus.VALIDATED.value + + session.add(version) + session.add(campaign) + session.commit() + return report_json diff --git a/src/govoplan_campaign/backend/persistence/versions.py b/src/govoplan_campaign/backend/persistence/versions.py new file mode 100644 index 0000000..59e3eac --- /dev/null +++ b/src/govoplan_campaign/backend/persistence/versions.py @@ -0,0 +1,827 @@ +from __future__ import annotations + +import copy +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignIssue, + CampaignStatus, + CampaignVersion, + CampaignVersionFlow, + CampaignVersionWorkflowState, + CampaignJob, + 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.persistence.campaigns import ( + CampaignPersistenceError, + _next_version_number, + _write_campaign_snapshot, + normalize_campaign_paths, +) + + +class LockedCampaignVersionError(CampaignPersistenceError): + """Raised when a caller tries to edit an immutable campaign version.""" + + +USER_LOCK_TEMPORARY = "temporary" +USER_LOCK_PERMANENT = "permanent" +USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT} + + +def campaign_version_user_lock_state(version: CampaignVersion) -> str | None: + """Return the explicit user-lock state with backwards compatibility. + + Older databases represented a permanent user lock only through + published_at. Treat those rows as permanent until the migration has + backfilled the explicit state. + """ + + state = getattr(version, "user_lock_state", None) + if state in USER_LOCK_STATES: + return state + if version.published_at: + return USER_LOCK_PERMANENT + return None + + +def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]: + """Return a WebUI-friendly starter campaign JSON. + + It is intentionally usable as an editable working copy. It contains the + main sections the UI expects, but it may still be incomplete from the + strict send/build perspective until the user configures recipients, + template and sender details. + """ + + return { + "version": "1.0", + "campaign": { + "id": external_id, + "name": name, + "description": description or "", + "mode": "draft", + }, + "fields": [], + "global_values": {}, + "server": { + "inherit_smtp_credentials": True, + "inherit_imap_credentials": True, + "smtp": { + "host": "", + "port": 587, + "username": "", + "password": "", + "security": "starttls", + }, + "imap": { + "enabled": False, + "host": "", + "port": 993, + "username": "", + "password": "", + "security": "tls", + "sent_folder": "auto", + }, + }, + "recipients": { + "from": [], + "allow_individual_from": False, + "to": [], + "allow_individual_to": True, + "cc": [], + "allow_individual_cc": False, + "bcc": [], + "allow_individual_bcc": False, + "reply_to": [], + "allow_individual_reply_to": False, + "bounce_to": [], + "allow_individual_bounce_to": False, + "disposition_notification_to": [], + "allow_individual_disposition_notification_to": False, + }, + "template": { + "subject": "", + "text": "", + "html": None, + }, + "attachments": { + "base_path": ".", + "base_paths": [ + { + "id": "default", + "name": "Campaign files", + "path": ".", + "allow_individual": True, + "unsent_warning": False, + } + ], + "allow_individual": True, + "send_without_attachments": False, + "global": [], + "zip": {"enabled": False, "archives": []}, + "missing_behavior": "ask", + "ambiguous_behavior": "ask", + }, + "entries": { + "inline": [], + "defaults": { + "active": True, + "merge_to": False, + "merge_cc": True, + "merge_bcc": True, + "merge_reply_to": True, + "merge_bounce_to": True, + "merge_disposition_notification_to": True, + "combine_attachments": True, + "attachments": [], + }, + }, + "validation_policy": { + "missing_required_attachment": "ask", + "missing_optional_attachment": "warn", + "ambiguous_attachment_match": "ask", + "unsent_attachment_files": "warn", + "ignore_empty_fields": False, + "missing_email": "block", + "template_error": "block", + }, + "delivery": { + "rate_limit": { + "messages_per_minute": 5, + "concurrency": 1, + }, + "imap_append_sent": { + "enabled": False, + "folder": "auto", + }, + "retry": { + "max_attempts": 3, + "backoff_seconds": [60, 300, 900], + }, + }, + "status_tracking": { + "enabled": True, + }, + } + + +def create_minimal_campaign( + session: Session, + *, + tenant_id: str, + user_id: str | None, + external_id: str, + name: str, + description: str | None = None, + current_flow: str = CampaignVersionFlow.CREATE.value, + current_step: str = "basics", +) -> tuple[Campaign, CampaignVersion]: + existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none() + if existing: + raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant") + + campaign = Campaign( + tenant_id=tenant_id, + created_by_user_id=user_id, + owner_user_id=user_id, + external_id=external_id, + name=name, + description=description, + status=CampaignStatus.DRAFT.value, + ) + session.add(campaign) + session.flush() + + version = CampaignVersion( + campaign_id=campaign.id, + version_number=1, + raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description), + schema_version="1.0", + workflow_state=CampaignVersionWorkflowState.EDITING.value, + current_flow=current_flow, + current_step=current_step, + is_complete=False, + editor_state={"created_from": "minimal_campaign"}, + autosaved_at=datetime.now(UTC), + ) + session.add(version) + session.flush() + campaign.current_version_id = version.id + session.add(campaign) + _write_campaign_snapshot(version) + session.commit() + return campaign, version + + +def get_campaign_version_for_tenant( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, +) -> CampaignVersion: + campaign = session.get(Campaign, campaign_id) + version = session.get(CampaignVersion, version_id) + if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id: + raise CampaignPersistenceError("Campaign version not found") + return version + + + + +LOCKED_WORKFLOW_STATES = { + CampaignVersionWorkflowState.APPROVED.value, + CampaignVersionWorkflowState.BUILT.value, + CampaignVersionWorkflowState.QUEUED.value, + CampaignVersionWorkflowState.SENDING.value, + CampaignVersionWorkflowState.COMPLETED.value, + CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value, + CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value, + CampaignVersionWorkflowState.FAILED.value, + CampaignVersionWorkflowState.CANCELLED.value, + CampaignVersionWorkflowState.ARCHIVED.value, +} + + +def is_version_locked(version: CampaignVersion) -> bool: + """Return True when a version is immutable and edits must fork/unlock.""" + + return bool( + version.locked_at + or campaign_version_user_lock_state(version) + or version.workflow_state in LOCKED_WORKFLOW_STATES + ) + + +def ensure_current_working_version(campaign: Campaign, version: CampaignVersion, *, action: str = "modify") -> None: + """Require the campaign's single active working version. + + Historical versions remain reviewable, but they never become writable in + place. Continuing from immutable history must create a new working copy, + and that copy becomes the campaign's sole current version. + """ + + if campaign.current_version_id != version.id: + raise LockedCampaignVersionError( + f"Historical campaign versions are read-only and cannot be used to {action}. " + "Open the current working version instead." + ) + + +def campaign_has_active_working_version(session: Session, campaign: Campaign) -> bool: + """Return True while the campaign already has a non-final working version. + + Validation locks and temporary user locks are still the same working + version; they must be unlocked rather than forked into parallel drafts. + """ + + if not campaign.current_version_id: + return False + current = session.get(CampaignVersion, campaign.current_version_id) + if not current or current.campaign_id != campaign.id: + return False + return not is_audit_safe_version(current) + + +def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None: + campaign_meta = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} + if campaign_meta: + campaign.name = campaign_meta.get("name") or campaign.name + campaign.description = campaign_meta.get("description", campaign.description) + campaign.external_id = campaign_meta.get("id") or campaign.external_id + + +def fork_campaign_version_for_edit( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, + raw_json: dict[str, Any] | None = None, + current_flow: str | None = None, + current_step: str | None = None, + editor_state: dict[str, Any] | None = None, + source_filename: str | None = None, + source_base_path: str | None = None, + autosave: bool = True, +) -> CampaignVersion: + """Create the next sole working version from immutable campaign history. + + Validation and temporary user locks are still the active working version + and must be unlocked in place. A copy is allowed only once the current + version is permanently user-locked or delivery-final. + """ + + source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + + if campaign_has_active_working_version(session, campaign): + current = session.get(CampaignVersion, campaign.current_version_id) + current_number = current.version_number if current else "current" + raise LockedCampaignVersionError( + f"Campaign already has active working version #{current_number}. " + "Unlock or continue editing that version instead of creating a parallel draft." + ) + if campaign.current_version_id and source.id != campaign.current_version_id: + raise LockedCampaignVersionError( + "Historical versions remain review-only and cannot become a new branch. " + "Create the next working copy from the campaign's current immutable version." + ) + + 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) + + new_version = CampaignVersion( + campaign_id=campaign.id, + version_number=_next_version_number(session, campaign.id), + raw_json=runtime_json, + schema_version=str(runtime_json.get("version", source.schema_version or "1.0")), + source_filename=source_filename if source_filename is not None else source.source_filename, + source_base_path=source_base_path if source_base_path is not None else source.source_base_path, + workflow_state=CampaignVersionWorkflowState.EDITING.value, + current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value), + current_step=current_step if current_step is not None else source.current_step, + is_complete=False, + editor_state=editor_state if editor_state is not None else copy.deepcopy(source.editor_state or {}), + autosaved_at=datetime.now(UTC) if autosave else None, + ) + session.add(new_version) + session.flush() + + _apply_campaign_metadata(campaign, runtime_json) + campaign.current_version_id = new_version.id + campaign.status = CampaignStatus.DRAFT.value + session.add(campaign) + _write_campaign_snapshot(new_version) + session.commit() + return new_version + + +def lock_validated_version(version: CampaignVersion, *, user_id: str | None = None) -> None: + if version.locked_at is None: + version.locked_at = datetime.now(UTC) + version.locked_by_user_id = user_id + + +def is_version_final_locked(version: CampaignVersion) -> bool: + """Return True when a version is part of or past delivery and must stay immutable.""" + + return version.workflow_state in { + CampaignVersionWorkflowState.QUEUED.value, + CampaignVersionWorkflowState.SENDING.value, + CampaignVersionWorkflowState.COMPLETED.value, + CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value, + CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value, + CampaignVersionWorkflowState.FAILED.value, + CampaignVersionWorkflowState.CANCELLED.value, + CampaignVersionWorkflowState.ARCHIVED.value, + } + + +def is_temporary_user_locked_version(version: CampaignVersion) -> bool: + return campaign_version_user_lock_state(version) == USER_LOCK_TEMPORARY + + +def is_permanent_user_locked_version(version: CampaignVersion) -> bool: + return campaign_version_user_lock_state(version) == USER_LOCK_PERMANENT + + +def is_user_locked_version(version: CampaignVersion) -> bool: + """Return True for either reversible or permanent user-requested locks.""" + + return campaign_version_user_lock_state(version) is not None + + +def is_audit_safe_version(version: CampaignVersion) -> bool: + """Return True when a version is immutable and cannot be unlocked.""" + + return is_permanent_user_locked_version(version) or is_version_final_locked(version) + + +def is_version_validated_and_locked(version: CampaignVersion) -> bool: + """Return True when the version was successfully validated and locked as a review snapshot.""" + + validation = version.validation_summary if isinstance(version.validation_summary, dict) else {} + return bool(version.locked_at and validation.get("ok") is True) + + +def unlock_validated_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, +) -> CampaignVersion: + """Unlock a validation snapshot so it can be edited again. + + This is only allowed before delivery starts. Unlocking invalidates validation, + build output and queued job records for that version. Sent/final versions must + be copied instead. + """ + + version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="unlock") + + if is_temporary_user_locked_version(version): + raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.") + if is_permanent_user_locked_version(version): + raise LockedCampaignVersionError("This version is permanently locked and cannot be unlocked. Create an editable copy instead.") + if is_version_final_locked(version): + raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.") + + # A version with sent jobs is final even if workflow_state was not updated for some reason. + sent_jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.campaign_version_id == version.id, + CampaignJob.send_status.in_([JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value]), + ) + .count() + ) + if sent_jobs: + raise LockedCampaignVersionError("This version has sent messages and cannot be unlocked. Create an editable copy instead.") + + version.locked_at = None + version.locked_by_user_id = None + version.validation_summary = None + version.build_summary = None + clear_execution_snapshot(version) + editor_state = copy.deepcopy(version.editor_state or {}) + editor_state.pop("review_send", None) + version.editor_state = editor_state + version.workflow_state = CampaignVersionWorkflowState.EDITING.value + version.is_complete = False + + session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False) + session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False) + + campaign.current_version_id = version.id + campaign.status = CampaignStatus.DRAFT.value + session.add(version) + session.add(campaign) + session.commit() + return version + +def update_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, + raw_json: dict[str, Any] | None = None, + current_flow: str | None = None, + current_step: str | None = None, + workflow_state: str | None = None, + is_complete: bool | None = None, + editor_state: dict[str, Any] | None = None, + source_filename: str | None = None, + source_base_path: str | None = None, + autosave: bool = False, +) -> CampaignVersion: + version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="edit") + + if is_version_locked(version): + raise LockedCampaignVersionError( + "Campaign version is locked. Create an editable copy before changing campaign data." + ) + + 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) + 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) + + if current_flow is not None: + version.current_flow = current_flow + if current_step is not None: + version.current_step = current_step + if workflow_state is not None: + version.workflow_state = workflow_state + if is_complete is not None: + version.is_complete = is_complete + if editor_state is not None: + version.editor_state = editor_state + if source_filename is not None: + version.source_filename = source_filename + if source_base_path is not None: + version.source_base_path = source_base_path + if autosave: + version.autosaved_at = datetime.now(UTC) + + # Changes invalidate previous build and validation summaries. + if raw_json is not None: + version.validation_summary = None + version.build_summary = None + clear_execution_snapshot(version) + version.locked_at = None + version.locked_by_user_id = None + if version.workflow_state != CampaignVersionWorkflowState.EDITING.value: + version.workflow_state = CampaignVersionWorkflowState.EDITING.value + campaign.status = CampaignStatus.DRAFT.value + session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False) + + session.add(version) + session.add(campaign) + session.flush() + _write_campaign_snapshot(version) + session.commit() + return version + + +def update_campaign_review_state( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, + inspection_complete: bool, + reviewed_message_keys: list[str], + user_id: str | None, +) -> CampaignVersion: + """Persist review acknowledgement without mutating the locked campaign data. + + Validation locks make the campaign JSON immutable, but review metadata is + operational state attached to a specific build. It is therefore stored in + editor_state and tied to the current build token so a rebuild invalidates it. + """ + + version = get_campaign_version_for_tenant( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="record review state for") + if is_version_final_locked(version): + raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.") + build_summary = version.build_summary if isinstance(version.build_summary, dict) else {} + if not build_summary: + raise CampaignPersistenceError("Build messages before recording review state.") + build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip() + if not build_token: + # Backwards-compatible upgrade for build summaries created before + # review-state tokens were introduced. + build_token = uuid4().hex + build_summary = copy.deepcopy(build_summary) + build_summary["build_token"] = build_token + version.build_summary = build_summary + + normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip())) + if inspection_complete: + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.campaign_version_id == version.id) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"] + if blocking: + raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.") + reviewed = set(normalized_reviewed) + explicit = { + str(job.entry_id or job.entry_index) + for job in jobs + if job.validation_status == "needs_review" + } + missing = sorted(explicit - reviewed) + if missing: + raise CampaignPersistenceError( + "Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing) + ) + bulk_acceptable = [ + str(job.entry_id or job.entry_index) + for job in jobs + if job.validation_status in {"warning", "excluded"} + ] + normalized_reviewed = list(dict.fromkeys([*normalized_reviewed, *bulk_acceptable])) + + editor_state = copy.deepcopy(version.editor_state or {}) + editor_state["review_send"] = { + "build_token": build_token, + "inspection_complete": bool(inspection_complete), + "reviewed_message_keys": normalized_reviewed, + "updated_at": datetime.now(UTC).isoformat(), + "updated_by_user_id": user_id, + } + version.editor_state = editor_state + session.add(version) + session.commit() + return version + + +def lock_campaign_version_temporarily( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, + user_id: str | None, +) -> CampaignVersion: + """Apply a reversible user-requested lock without changing workflow state.""" + + version = get_campaign_version_for_tenant( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="lock") + if is_version_final_locked(version): + raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.") + if is_permanent_user_locked_version(version): + raise LockedCampaignVersionError("This version is already permanently locked.") + if is_temporary_user_locked_version(version): + return version + if version.locked_at: + raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.") + + version.user_lock_state = USER_LOCK_TEMPORARY + version.user_locked_at = datetime.now(UTC) + version.user_locked_by_user_id = user_id + session.add(version) + session.commit() + return version + + +def unlock_user_locked_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, +) -> CampaignVersion: + """Remove a reversible user lock without invalidating campaign data.""" + + version = get_campaign_version_for_tenant( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="unlock") + state = campaign_version_user_lock_state(version) + if state == USER_LOCK_PERMANENT: + raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.") + if state != USER_LOCK_TEMPORARY: + raise LockedCampaignVersionError("This version does not have a temporary user lock.") + if is_version_final_locked(version): + raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.") + + version.user_lock_state = None + version.user_locked_at = None + version.user_locked_by_user_id = None + session.add(version) + session.commit() + return version + + +def permanently_lock_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, + user_id: str | None, +) -> CampaignVersion: + """Apply an irreversible user lock. + + The version remains in its current workflow state so the campaign itself is + not silently archived. Future changes must be made in an editable copy. + """ + + version = get_campaign_version_for_tenant( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + ensure_current_working_version(campaign, version, action="lock permanently") + if is_version_final_locked(version): + raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.") + if is_permanent_user_locked_version(version): + return version + + now = datetime.now(UTC) + version.user_lock_state = USER_LOCK_PERMANENT + version.user_locked_at = now + version.user_locked_by_user_id = user_id + # Retain published_at as a compatibility marker for existing integrations. + version.published_at = version.published_at or now + session.add(version) + session.commit() + return version + + +def publish_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, + user_id: str | None = None, +) -> CampaignVersion: + """Backwards-compatible alias for the permanent user lock.""" + + return permanently_lock_campaign_version( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + version_id=version_id, + user_id=user_id, + ) + + +def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]: + """Lightweight UI-facing validation for incomplete campaign working copies. + + This is intentionally less strict than campaign.schema.json validation. It + lets the WebUI autosave and validate one wizard step at a time. + """ + + issues: list[dict[str, Any]] = [] + + def issue(severity: str, sec: str, field: str, code: str, message: str) -> None: + if section is None or section == sec: + issues.append({ + "severity": severity, + "section": sec, + "field": field, + "code": code, + "message": message, + }) + + campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} + if not campaign.get("id"): + issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.") + if not campaign.get("name"): + issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.") + + recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {} + sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {} + if not sender.get("email"): + issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.") + + entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {} + has_inline = bool(entries.get("inline")) + has_source = isinstance(entries.get("source"), dict) + if not has_inline and not has_source: + issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.") + if has_source: + mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {} + if not any(key in mapping for key in ("to.0.email", "to.email", "email")): + issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.") + + 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")): + 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): + 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 {} + base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else [] + has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths) + if not has_named_base_path and not attachments.get("base_path"): + issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.") + + delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {} + rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {} + messages_per_minute = rate_limit.get("messages_per_minute") + if messages_per_minute is not None: + try: + if int(messages_per_minute) < 1: + issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.") + except (TypeError, ValueError): + issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.") + + return { + "ok": not any(item["severity"] == "error" for item in issues), + "section": section, + "error_count": sum(1 for item in issues if item["severity"] == "error"), + "warning_count": sum(1 for item in issues if item["severity"] == "warning"), + "info_count": sum(1 for item in issues if item["severity"] == "info"), + "issues": issues, + } diff --git a/src/govoplan_campaign/backend/reports/__init__.py b/src/govoplan_campaign/backend/reports/__init__.py new file mode 100644 index 0000000..fe46cb0 --- /dev/null +++ b/src/govoplan_campaign/backend/reports/__init__.py @@ -0,0 +1 @@ +"""Reporting helpers for campaigns and jobs.""" diff --git a/src/govoplan_campaign/backend/reports/campaigns.py b/src/govoplan_campaign/backend/reports/campaigns.py new file mode 100644 index 0000000..86299a6 --- /dev/null +++ b/src/govoplan_campaign/backend/reports/campaigns.py @@ -0,0 +1,429 @@ +from __future__ import annotations + +import csv +import io +import math +from collections import Counter +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignVersion, + ImapAppendAttempt, + SendAttempt, +) +from govoplan_campaign.backend.sending.execution import ExecutionSnapshot + + +class CampaignReportError(RuntimeError): + pass + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _counter(values: list[str | None]) -> dict[str, int]: + return dict(Counter(value or "unknown" for value in values)) + + +def _get_campaign(session: Session, *, tenant_id: str, campaign_id: str) -> Campaign: + campaign = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.id == campaign_id).one_or_none() + if not campaign: + raise CampaignReportError(f"Campaign not found or not accessible: {campaign_id}") + return campaign + + +def _selected_version( + session: Session, + campaign: Campaign, + version_id: str | None = None, +) -> CampaignVersion | None: + wanted = version_id or campaign.current_version_id + if not wanted: + return None + version = session.get(CampaignVersion, wanted) + if not version or version.campaign_id != campaign.id: + raise CampaignReportError(f"Campaign version not found or not part of campaign: {wanted}") + return version + + +def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None: + if not version: + return None + return { + "id": version.id, + "version_number": version.version_number, + "schema_version": version.schema_version, + "source_filename": version.source_filename, + "created_at": version.created_at.isoformat() if version.created_at else None, + "validation_summary": version.validation_summary, + "build_summary": version.build_summary, + "execution_snapshot_hash": version.execution_snapshot_hash, + "execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None, + } + + +def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, Any]: + """Read deterministic delivery settings from the immutable execution snapshot.""" + + default = { + "rate_limit": {"messages_per_minute": None, "concurrency": None}, + "imap_append_sent": {"enabled": None, "folder": None}, + "retry": {"max_attempts": None, "backoff_seconds": []}, + "execution_snapshot_hash": None, + "execution_snapshot_at": None, + "snapshot_version": None, + "build_token": None, + "built_at": None, + "job_manifest_sha256": None, + "job_count": 0, + "queueable_job_count": 0, + "effective_policy_sha256": None, + "smtp_config_fingerprint": None, + "imap_config_fingerprint": None, + "estimated_remaining_send_seconds": None, + "estimated_remaining_send_human": None, + } + if not version or not isinstance(version.execution_snapshot, dict): + default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery." + return default + try: + snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot) + except Exception as exc: # pragma: no cover - reporting should remain available + default["load_error"] = str(exc) + return default + + messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute + pending = [job for job in jobs if job.send_status in {"queued", "claimed", "sending"}] + estimated_seconds = None + if messages_per_minute and pending: + estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60)) + + return { + "rate_limit": { + "messages_per_minute": messages_per_minute, + "concurrency": snapshot.delivery.rate_limit.concurrency, + }, + "imap_append_sent": { + "enabled": snapshot.delivery.imap_append_sent.enabled, + "folder": snapshot.delivery.imap_append_sent.folder, + }, + "retry": { + "max_attempts": snapshot.delivery.retry.max_attempts, + "backoff_seconds": snapshot.delivery.retry.backoff_seconds, + }, + "execution_snapshot_hash": version.execution_snapshot_hash, + "execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None, + "snapshot_version": snapshot.snapshot_version, + "build_token": snapshot.build_token, + "built_at": snapshot.built_at, + "job_manifest_sha256": snapshot.job_manifest_sha256, + "job_count": snapshot.job_count, + "queueable_job_count": snapshot.queueable_job_count, + "effective_policy_sha256": snapshot.effective_policy_sha256, + "smtp_config_fingerprint": snapshot.smtp_config_fingerprint, + "imap_config_fingerprint": snapshot.imap_config_fingerprint, + "estimated_remaining_send_seconds": estimated_seconds, + "estimated_remaining_send_human": _human_duration(estimated_seconds), + } + + +def _human_duration(seconds: int | None) -> str | None: + if seconds is None: + return None + if seconds < 60: + return f"{seconds}s" + minutes, sec = divmod(seconds, 60) + if minutes < 60: + return f"{minutes}m {sec}s" if sec else f"{minutes}m" + hours, minute = divmod(minutes, 60) + return f"{hours}h {minute}m" if minute else f"{hours}h" + + +def _issue_summary_from_jobs(jobs: list[CampaignJob]) -> dict[str, Any]: + severity_counter: Counter[str] = Counter() + code_counter: Counter[str] = Counter() + behavior_counter: Counter[str] = Counter() + total = 0 + for job in jobs: + for issue in job.issues_snapshot or []: + if not isinstance(issue, dict): + continue + total += 1 + severity_counter[issue.get("severity") or "unknown"] += 1 + code_counter[issue.get("code") or "unknown"] += 1 + if issue.get("behavior"): + behavior_counter[issue["behavior"]] += 1 + return { + "total": total, + "by_severity": dict(severity_counter), + "by_code": dict(code_counter), + "by_behavior": dict(behavior_counter), + } + + +def _attachment_summary(jobs: list[CampaignJob]) -> dict[str, Any]: + status_counter: Counter[str] = Counter() + behavior_counter: Counter[str] = Counter() + total_configs = 0 + total_matched_files = 0 + zip_enabled = 0 + missing = 0 + ambiguous = 0 + for job in jobs: + for attachment in job.resolved_attachments or []: + if not isinstance(attachment, dict): + continue + total_configs += 1 + status = attachment.get("status") or "unknown" + status_counter[status] += 1 + if attachment.get("behavior"): + behavior_counter[attachment["behavior"]] += 1 + matches = attachment.get("matches") or [] + if isinstance(matches, list): + total_matched_files += len(matches) + if attachment.get("zip_enabled"): + zip_enabled += 1 + if status == "missing": + missing += 1 + if status == "ambiguous": + ambiguous += 1 + return { + "total_attachment_configs": total_configs, + "total_matched_files": total_matched_files, + "zip_enabled_configs": zip_enabled, + "missing_configs": missing, + "ambiguous_configs": ambiguous, + "by_status": dict(status_counter), + "by_behavior": dict(behavior_counter), + } + + +def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]: + failed = [job for job in jobs if job.last_error or str(job.send_status).startswith("failed") or job.imap_status == "failed"] + failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True) + return [ + { + "job_id": job.id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "validation_status": job.validation_status, + "send_status": job.send_status, + "imap_status": job.imap_status, + "attempt_count": job.attempt_count, + "last_error": job.last_error, + "updated_at": job.updated_at.isoformat() if job.updated_at else None, + } + for job in failed[:limit] + ] + + +def _job_row(job: CampaignJob) -> dict[str, Any]: + return { + "job_id": job.id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "subject": job.subject, + "build_status": job.build_status, + "validation_status": job.validation_status, + "queue_status": job.queue_status, + "send_status": job.send_status, + "imap_status": job.imap_status, + "attempt_count": job.attempt_count, + "queued_at": job.queued_at.isoformat() if job.queued_at else None, + "claimed_at": job.claimed_at.isoformat() if job.claimed_at else None, + "smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None, + "outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None, + "sent_at": job.sent_at.isoformat() if job.sent_at else None, + "last_error": job.last_error, + "eml_size_bytes": job.eml_size_bytes, + "eml_sha256": job.eml_sha256, + "issues_count": len(job.issues_snapshot or []), + "attachment_config_count": len(job.resolved_attachments or []), + "matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)), + } + + +def generate_campaign_report( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + include_jobs: bool = False, + include_recent_failures: bool = True, +) -> dict[str, Any]: + """Generate a dashboard/report payload for one campaign. + + The shape is intentionally web-UI friendly: status counters for cards, + issue/attachment summaries for review panels, and optional job rows for + tables/export. + """ + + campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id) + version = _selected_version(session, campaign, version_id) + jobs_query = session.query(CampaignJob).filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + ) + if version: + jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id) + else: + jobs_query = jobs_query.filter(False) + jobs = ( + jobs_query + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + job_ids = [job.id for job in jobs] + send_attempts = session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count() if job_ids else 0 + imap_attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count() if job_ids else 0 + issue_query = session.query(CampaignIssue).filter( + CampaignIssue.tenant_id == tenant_id, + CampaignIssue.campaign_id == campaign.id, + ) + if version: + issue_query = issue_query.filter(CampaignIssue.campaign_version_id == version.id) + else: + issue_query = issue_query.filter(False) + persisted_issues = issue_query.count() + + validation_counts = _counter([job.validation_status for job in jobs]) + queue_counts = _counter([job.queue_status for job in jobs]) + send_counts = _counter([job.send_status for job in jobs]) + imap_counts = _counter([job.imap_status for job in jobs]) + build_counts = _counter([job.build_status for job in jobs]) + + queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built") + needs_attention = sum( + 1 + for job in jobs + if job.validation_status in {"needs_review", "blocked"} + or job.send_status in {"failed_temporary", "failed_permanent", "outcome_unknown", "claimed", "sending"} + or job.imap_status == "failed" + ) + sent = send_counts.get("sent", 0) + send_counts.get("smtp_accepted", 0) + failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0) + outcome_unknown = send_counts.get("outcome_unknown", 0) + not_attempted = send_counts.get("not_queued", 0) + queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0) + cancelled = send_counts.get("cancelled", 0) + build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {} + inactive_entries = int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0) + if inactive_entries: + validation_counts["inactive"] = inactive_entries + + report: dict[str, Any] = { + "generated_at": _utcnow_iso(), + "campaign": { + "id": campaign.id, + "external_id": campaign.external_id, + "name": campaign.name, + "description": campaign.description, + "status": campaign.status, + "created_at": campaign.created_at.isoformat() if campaign.created_at else None, + "updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None, + }, + "current_version": _version_info(version), + "selected_version_id": version.id if version else None, + "cards": { + "jobs_total": len(jobs), + "inactive": inactive_entries, + "queueable": queueable, + "needs_attention": needs_attention, + "sent": sent, + "smtp_accepted": sent, + "failed": failed, + "outcome_unknown": outcome_unknown, + "not_attempted": not_attempted, + "queued_or_active": queued, + "cancelled": cancelled, + "partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)), + "imap_appended": imap_counts.get("appended", 0), + "imap_failed": imap_counts.get("failed", 0), + }, + "status_counts": { + "build": build_counts, + "validation": validation_counts, + "queue": queue_counts, + "send": send_counts, + "imap": imap_counts, + }, + "issues": { + **_issue_summary_from_jobs(jobs), + "persisted_campaign_issue_count": persisted_issues, + }, + "attachments": _attachment_summary(jobs), + "attempts": { + "send_attempts": int(send_attempts), + "imap_append_attempts": int(imap_attempts), + }, + "delivery": _load_delivery_info(version, jobs), + } + if include_recent_failures: + report["recent_failures"] = _recent_failures(jobs) + if include_jobs: + report["jobs"] = [_job_row(job) for job in jobs] + return report + + +def generate_jobs_csv( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, +) -> str: + campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id) + version = _selected_version(session, campaign, version_id) + jobs_query = session.query(CampaignJob).filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + ) + if version: + jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id) + else: + jobs_query = jobs_query.filter(False) + jobs = ( + jobs_query + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + rows = [_job_row(job) for job in jobs] + fieldnames = [ + "job_id", + "entry_index", + "entry_id", + "recipient_email", + "subject", + "build_status", + "validation_status", + "queue_status", + "send_status", + "imap_status", + "attempt_count", + "queued_at", + "claimed_at", + "smtp_started_at", + "outcome_unknown_at", + "sent_at", + "last_error", + "eml_size_bytes", + "eml_sha256", + "issues_count", + "attachment_config_count", + "matched_file_count", + ] + buffer = io.StringIO() + writer = csv.DictWriter(buffer, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + return buffer.getvalue() diff --git a/src/govoplan_campaign/backend/reports/emailing.py b/src/govoplan_campaign/backend/reports/emailing.py new file mode 100644 index 0000000..037c0ca --- /dev/null +++ b/src/govoplan_campaign/backend/reports/emailing.py @@ -0,0 +1,223 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr +from typing import Any + +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.db.models import Campaign, CampaignVersion +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 + + +class CampaignReportEmailError(RuntimeError): + pass + + +@dataclass(slots=True) +class CampaignReportEmailResult: + campaign_id: str + version_id: str + to: list[str] + subject: str + dry_run: bool + sent: bool + attached_jobs_csv: bool + attached_report_json: bool + smtp_host: str | None = None + smtp_port: int | None = None + accepted_count: int | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "campaign_id": self.campaign_id, + "version_id": self.version_id, + "to": self.to, + "subject": self.subject, + "dry_run": self.dry_run, + "sent": self.sent, + "attached_jobs_csv": self.attached_jobs_csv, + "attached_report_json": self.attached_report_json, + "smtp_host": self.smtp_host, + "smtp_port": self.smtp_port, + "accepted_count": self.accepted_count, + } + + +def _selected_version( + session: Session, + campaign: Campaign, + version_id: str | None = None, +) -> CampaignVersion: + wanted = version_id or campaign.current_version_id + version = session.get(CampaignVersion, wanted) if wanted else None + if version is None or version.campaign_id != campaign.id: + raise CampaignReportEmailError("Campaign version not found") + return version + + +def _load_config(version: CampaignVersion) -> CampaignConfig: + snapshot_path = _write_campaign_snapshot(version) + return load_campaign_config(snapshot_path) + + +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 + raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address") + + +def _text_summary(report: dict[str, Any]) -> str: + campaign = report["campaign"] + cards = report["cards"] + status = report["status_counts"] + delivery = report.get("delivery", {}) + lines = [ + f"Campaign report: {campaign['name']}", + "", + f"Campaign ID: {campaign['id']}", + f"External ID: {campaign['external_id']}", + f"Status: {campaign['status']}", + "", + "Overview", + f"- Jobs total: {cards['jobs_total']}", + f"- Queueable: {cards['queueable']}", + f"- Needs attention: {cards['needs_attention']}", + f"- Sent: {cards['sent']}", + f"- Failed: {cards['failed']}", + f"- IMAP appended: {cards['imap_appended']}", + f"- IMAP failed: {cards['imap_failed']}", + "", + f"Build status: {status.get('build', {})}", + f"Validation status: {status.get('validation', {})}", + f"Queue status: {status.get('queue', {})}", + f"Send status: {status.get('send', {})}", + f"IMAP status: {status.get('imap', {})}", + ] + if delivery.get("estimated_remaining_send_human"): + lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"]) + lines.extend(["", "This report was generated by MultiMailer."]) + return "\n".join(lines) + + +def build_report_message( + *, + campaign: Campaign, + config: CampaignConfig, + report: dict[str, Any], + to: list[str], + jobs_csv: str | None = None, + report_json: dict[str, Any] | None = None, +) -> EmailMessage: + from_email, from_name = _effective_from(config) + subject = f"MultiMailer report: {campaign.name}" + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = formataddr((from_name or from_email, from_email)) + msg["To"] = ", ".join(to) + msg["X-MultiMailer-Report"] = "campaign" + msg.set_content(_text_summary(report)) + + if jobs_csv is not None: + filename = f"multimailer-{campaign.external_id}-jobs.csv" + msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename) + if report_json is not None: + filename = f"multimailer-{campaign.external_id}-report.json" + msg.add_attachment( + json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"), + maintype="application", + subtype="json", + filename=filename, + ) + return msg + + +def send_campaign_report_email( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + to: list[str], + include_jobs: bool = False, + attach_jobs_csv: bool = True, + attach_report_json: bool = False, + dry_run: bool = False, +) -> CampaignReportEmailResult: + campaign = session.get(Campaign, campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise CampaignReportError("Campaign not found") + if not to: + raise CampaignReportEmailError("At least one report recipient is required") + + version = _selected_version(session, campaign, version_id) + config = _load_config(version) + smtp_config: SmtpConfig | None = config.server.smtp + if smtp_config is None: + raise SmtpConfigurationError("Campaign has no SMTP configuration") + + report = generate_campaign_report( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + version_id=version.id, + include_jobs=include_jobs, + ) + jobs_csv = ( + generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id) + if attach_jobs_csv + else None + ) + report_json = report if attach_report_json else None + message = build_report_message( + campaign=campaign, + config=config, + report=report, + to=to, + jobs_csv=jobs_csv, + report_json=report_json, + ) + envelope_from, _ = _effective_from(config) + + if dry_run: + return CampaignReportEmailResult( + campaign_id=campaign.id, + version_id=version.id, + to=to, + subject=str(message["Subject"]), + dry_run=True, + sent=False, + attached_jobs_csv=jobs_csv is not None, + attached_report_json=report_json is not None, + smtp_host=smtp_config.host, + smtp_port=smtp_config.port, + ) + + result: SmtpSendResult = send_email_message( + message, + smtp_config=smtp_config, + envelope_from=envelope_from, + envelope_recipients=to, + ) + return CampaignReportEmailResult( + campaign_id=campaign.id, + version_id=version.id, + to=to, + subject=str(message["Subject"]), + dry_run=False, + sent=True, + attached_jobs_csv=jobs_csv is not None, + attached_report_json=report_json is not None, + smtp_host=result.host, + smtp_port=result.port, + accepted_count=result.accepted_count, + ) diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py new file mode 100644 index 0000000..151e653 --- /dev/null +++ b/src/govoplan_campaign/backend/router.py @@ -0,0 +1,1953 @@ +from __future__ import annotations + +import copy + +from fastapi import APIRouter, Depends, HTTPException, Query, Response, status +from sqlalchemy import and_, exists, func, or_ +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field + +from govoplan_campaign.backend.schemas import ( + BuildCampaignRequest, + CampaignCreateRequest, + CampaignUpdateRequest, + CampaignCreateResponse, + CampaignCreateMinimalRequest, + CampaignShareItem, + CampaignShareListResponse, + CampaignShareTargetItem, + CampaignShareTargetsResponse, + CampaignShareUpsertRequest, + CampaignOwnerUpdateRequest, + CampaignJobsResponse, + CampaignJobDetailResponse, + CampaignRetryJobsRequest, + CampaignSendUnattemptedRequest, + CampaignResolveOutcomeRequest, + CampaignListResponse, + CampaignResponse, + CampaignVersionDetailResponse, + CampaignVersionResponse, + CampaignVersionSetStepRequest, + CampaignReviewStateRequest, + CampaignVersionUpdateRequest, + CampaignPartialValidationRequest, + CampaignPartialValidationResponse, + ValidateCampaignRequest, + ReportEmailRequest, + ReportEmailResponse, +) +from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope +from govoplan_core.audit.logging import audit_from_principal +from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignShare, CampaignVersion, Group, ImapAppendAttempt, SendAttempt, User, UserGroupMembership +from govoplan_core.db.session import get_session +from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv +from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email +from govoplan_campaign.backend.persistence.campaigns import ( + CampaignPersistenceError, + build_campaign_version, + create_campaign_version_from_json, + 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.campaign.loader import load_campaign_config, load_campaign_json +from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments +from govoplan_campaign.backend.time import utc_now +from govoplan_campaign.backend.persistence.versions import ( + LockedCampaignVersionError, + create_minimal_campaign, + fork_campaign_version_for_edit, + is_version_final_locked, + is_user_locked_version, + is_version_locked, + get_campaign_version_for_tenant, + lock_campaign_version_temporarily, + permanently_lock_campaign_version, + publish_campaign_version, + unlock_user_locked_campaign_version, + unlock_validated_campaign_version, + update_campaign_version, + update_campaign_review_state, + validate_campaign_partial, +) + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign: + campaign = session.get(Campaign, campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found") + return campaign + + +def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -> CampaignVersion: + version = session.get(CampaignVersion, version_id) + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") + campaign = session.get(Campaign, version.campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") + return version + + + + +def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]: + return { + row[0] + for row in session.query(UserGroupMembership.group_id) + .filter(UserGroupMembership.tenant_id == principal.tenant_id, UserGroupMembership.user_id == principal.user.id) + .all() + } + + + + +def _campaign_acl_filter(session: Session, principal: ApiPrincipal): + if has_scope(principal, "tenant:*"): + return None + group_ids = _principal_group_ids(session, principal) + clauses = [Campaign.owner_user_id == principal.user.id] + if group_ids: + clauses.append(Campaign.owner_group_id.in_(group_ids)) + share_clauses = [ + and_( + CampaignShare.tenant_id == Campaign.tenant_id, + CampaignShare.campaign_id == Campaign.id, + CampaignShare.revoked_at.is_(None), + CampaignShare.target_type == "user", + CampaignShare.target_id == principal.user.id, + ) + ] + if group_ids: + share_clauses.append( + and_( + CampaignShare.tenant_id == Campaign.tenant_id, + CampaignShare.campaign_id == Campaign.id, + CampaignShare.revoked_at.is_(None), + CampaignShare.target_type == "group", + CampaignShare.target_id.in_(group_ids), + ) + ) + clauses.append(exists().where(or_(*share_clauses))) + return or_(*clauses) + + +def _campaign_acl_allows(session: Session, campaign: Campaign, principal: ApiPrincipal, *, write: bool = False) -> bool: + if has_scope(principal, "tenant:*"): + return True + if campaign.owner_user_id == principal.user.id: + return True + group_ids = _principal_group_ids(session, principal) + if campaign.owner_group_id and campaign.owner_group_id in group_ids: + return True + target_ids = [principal.user.id, *group_ids] + if not target_ids: + return False + query = session.query(CampaignShare).filter( + CampaignShare.tenant_id == campaign.tenant_id, + CampaignShare.campaign_id == campaign.id, + CampaignShare.revoked_at.is_(None), + or_( + CampaignShare.target_type == "user", + CampaignShare.target_type == "group", + ), + CampaignShare.target_id.in_(target_ids), + ) + shares = query.all() + if not shares: + return False + if not write: + return True + return any(item.permission == "write" for item in shares) + + +def _require_campaign_acl(session: Session, campaign: Campaign, principal: ApiPrincipal, *, write: bool = False) -> None: + if not _campaign_acl_allows(session, campaign, principal, write=write): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Campaign is not shared with this principal") + + +def _get_campaign_for_principal(session: Session, campaign_id: str, principal: ApiPrincipal, *, write: bool = False) -> Campaign: + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + _require_campaign_acl(session, campaign, principal, write=write) + return campaign + + +def _require_permission(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}") + + +def _recipient_sections_changed(current: dict[str, object] | None, proposed: dict[str, object] | None) -> bool: + if proposed is None: + return False + current = current or {} + return any(current.get(key) != proposed.get(key) for key in ("recipients", "entries")) + + + + +def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None: + server = raw_json.get("server") if isinstance(raw_json, dict) else None + value = server.get("mail_profile_id") if isinstance(server, dict) else None + return str(value) if value else None + + +def _require_mail_profile_use_if_needed(principal: ApiPrincipal, raw_json: dict[str, object] | None) -> None: + if _campaign_mail_profile_id(raw_json) and not has_scope(principal, "mail:profile:use"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: mail_servers:use") + + +def _require_campaign_profile_use_if_needed( + session: Session, + principal: ApiPrincipal, + campaign_id: str, + version_id: str | None = None, +) -> None: + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + target_version_id = version_id or campaign.current_version_id + if not target_version_id: + return + version = _get_version_for_tenant(session, target_version_id, principal.tenant_id) + if version.campaign_id != campaign.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") + _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) + + +def _get_version_for_principal( + session: Session, + version_id: str, + principal: ApiPrincipal, + *, + write: bool = False, +) -> CampaignVersion: + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + campaign = _get_campaign_for_tenant(session, version.campaign_id, principal.tenant_id) + _require_campaign_acl(session, campaign, principal, write=write) + return version + + + +def _sync_campaign_metadata_to_current_version(session: Session, campaign: Campaign) -> None: + """Keep editable version JSON aligned with version-independent campaign metadata. + + Campaign metadata can be edited from the overview while individual campaign + sections save the current version JSON later. Without this sync, a later + version save can re-apply stale `campaign.name` / `campaign.id` values from + raw_json and make the old overview metadata appear to come back. Audit-safe + or validation-locked versions are left untouched. + """ + + if not campaign.current_version_id: + return + + version = session.get(CampaignVersion, campaign.current_version_id) + if not version or version.campaign_id != campaign.id or is_version_locked(version): + return + + raw_json = copy.deepcopy(version.raw_json if isinstance(version.raw_json, dict) else {}) + campaign_section = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} + raw_json["campaign"] = { + **campaign_section, + "id": campaign.external_id, + "name": campaign.name, + "description": campaign.description or "", + } + version.raw_json = raw_json + session.add(version) + + +@router.post("", response_model=CampaignCreateResponse) +def create_campaign( + payload: CampaignCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:create")), +): + try: + if payload.config.get("entries") or payload.config.get("recipients"): + _require_permission(principal, "campaigns:recipient:write") + _require_mail_profile_use_if_needed(principal, payload.config) + campaign, version = create_campaign_version_from_json( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + raw_json=payload.config, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + ) + audit_from_principal( + session, + principal, + action="campaign.created", + object_type="campaign", + object_id=campaign.id, + details={"version_id": version.id, "external_id": campaign.external_id}, + commit=True, + ) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version)) + + +@router.post("/new", response_model=CampaignCreateResponse) +def create_minimal_campaign_endpoint( + payload: CampaignCreateMinimalRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:create")), +): + """Create a minimal editable campaign/version for the WebUI wizard. + + This is intentionally different from importing a complete campaign JSON. It + returns a normal Campaign + CampaignVersion whose version is a working copy + and can be autosaved while incomplete. + """ + + try: + campaign, version = create_minimal_campaign( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + external_id=payload.external_id, + name=payload.name, + description=payload.description, + current_flow=payload.current_flow, + current_step=payload.current_step, + ) + audit_from_principal( + session, + principal, + action="campaign.created_minimal", + object_type="campaign", + object_id=campaign.id, + details={"version_id": version.id, "external_id": campaign.external_id}, + commit=True, + ) + return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version)) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + + +@router.get("", response_model=CampaignListResponse) +def list_campaigns( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + query = session.query(Campaign).filter(Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted") + acl_filter = _campaign_acl_filter(session, principal) + if acl_filter is not None: + query = query.filter(acl_filter) + campaigns = query.order_by(Campaign.updated_at.desc()).all() + return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns]) + + +@router.get("/{campaign_id}", response_model=CampaignResponse) +def get_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + return CampaignResponse.model_validate(_get_campaign_for_principal(session, campaign_id, principal)) + + +@router.put("/{campaign_id}", response_model=CampaignResponse) +def update_campaign_metadata_endpoint( + campaign_id: str, + payload: CampaignUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + if payload.external_id is not None: + value = payload.external_id.strip() + if not value: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign ID cannot be empty") + duplicate = ( + session.query(Campaign) + .filter(Campaign.tenant_id == principal.tenant_id, Campaign.external_id == value, Campaign.id != campaign.id) + .one_or_none() + ) + if duplicate: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Campaign ID already exists for this tenant") + campaign.external_id = value + if payload.name is not None: + value = payload.name.strip() + if not value: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign name cannot be empty") + campaign.name = value + if payload.status is not None: + campaign.status = payload.status + if payload.description is not None: + campaign.description = payload.description + + _sync_campaign_metadata_to_current_version(session, campaign) + session.add(campaign) + session.commit() + session.refresh(campaign) + audit_from_principal( + session, + principal, + action="campaign.metadata_updated", + object_type="campaign", + object_id=campaign.id, + details={"external_id": campaign.external_id, "name": campaign.name}, + commit=True, + ) + return CampaignResponse.model_validate(campaign) + + +@router.post("/{campaign_id}/archive", response_model=CampaignResponse) +def archive_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + if campaign.status in {"queued", "sending", "outcome_unknown"}: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Active or uncertain delivery must be resolved before archiving.") + campaign.status = "archived" + session.add(campaign) + audit_from_principal( + session, principal, action="campaign.archived", object_type="campaign", object_id=campaign.id, details={}, commit=True + ) + session.refresh(campaign) + return CampaignResponse.model_validate(campaign) + + +@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_draft_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + if campaign.status != "draft": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Only untouched draft campaigns can be deleted.") + if session.query(CampaignJob.id).filter(CampaignJob.campaign_id == campaign.id).first() is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Campaigns with built or delivery jobs must be archived instead of deleted.") + protected_version = ( + session.query(CampaignVersion.id) + .filter( + CampaignVersion.campaign_id == campaign.id, + or_( + CampaignVersion.locked_at.is_not(None), + CampaignVersion.user_lock_state.is_not(None), + CampaignVersion.published_at.is_not(None), + CampaignVersion.execution_snapshot_at.is_not(None), + ), + ) + .first() + ) + if protected_version is not None: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Audit-relevant campaign versions must be archived instead of deleted.") + campaign.status = "deleted" + session.add(campaign) + audit_from_principal( + session, principal, action="campaign.deleted", object_type="campaign", object_id=campaign.id, details={"mode": "soft"}, commit=True + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse]) +def list_versions( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + versions = ( + session.query(CampaignVersion) + .filter(CampaignVersion.campaign_id == campaign.id) + .order_by(CampaignVersion.version_number.desc()) + .all() + ) + return [CampaignVersionResponse.model_validate(item) for item in versions] + + +@router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) +def get_version_detail( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + try: + version = get_campaign_version_for_tenant( + session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id + ) + return CampaignVersionDetailResponse.model_validate(version) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse) +def fork_version_for_edit( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + """Create the campaign's next and only editable working version. + + A new working copy may be created only after the current version is + permanently user-locked or delivery-final. Validation and temporary user + locks must be removed in place instead of creating parallel drafts. + """ + + payload = payload or CampaignVersionUpdateRequest() + _require_mail_profile_use_if_needed(principal, payload.campaign_json) + try: + version = fork_campaign_version_for_edit( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow or "manual", + current_step=payload.current_step, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=True, + ) + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + audit_from_principal( + session, + principal, + action="campaign.version_forked_for_edit", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id, "source_version_id": version_id, "version_number": version.version_number}, + commit=True, + ) + return CampaignCreateResponse( + campaign=CampaignResponse.model_validate(campaign), + version=CampaignVersionResponse.model_validate(version), + ) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/unlock-validation", response_model=CampaignVersionDetailResponse) +def unlock_version_validation( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + """Unlock a successfully validated version before delivery starts. + + Unlocking invalidates validation/build state and removes generated jobs for + that version. Sent/final versions cannot be unlocked and must be copied. + """ + + try: + version = unlock_validated_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + audit_from_principal( + session, + principal, + action="campaign.version_validation_unlocked", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/lock-temporarily", response_model=CampaignVersionDetailResponse) +def lock_version_temporarily( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = lock_campaign_version_temporarily( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + user_id=principal.user.id, + ) + audit_from_principal( + session, + principal, + action="campaign.version_user_locked_temporarily", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/unlock-user-lock", response_model=CampaignVersionDetailResponse) +def unlock_version_user_lock( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = unlock_user_locked_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + ) + audit_from_principal( + session, + principal, + action="campaign.version_user_lock_removed", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/lock-permanently", response_model=CampaignVersionDetailResponse) +def lock_version_permanently( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = permanently_lock_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + user_id=principal.user.id, + ) + audit_from_principal( + session, + principal, + action="campaign.version_user_locked_permanently", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) +def update_version_detail( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): + _require_permission(principal, "campaigns:recipient:write") + _require_mail_profile_use_if_needed(principal, payload.campaign_json) + try: + version = update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow, + current_step=payload.current_step, + workflow_state=payload.workflow_state, + is_complete=payload.is_complete, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=False, + ) + audit_from_principal( + session, + principal, + action="campaign.version_updated", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse) +def autosave_version( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + current_version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if _recipient_sections_changed(current_version.raw_json, payload.campaign_json): + _require_permission(principal, "campaigns:recipient:write") + _require_mail_profile_use_if_needed(principal, payload.campaign_json) + try: + version = update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow, + current_step=payload.current_step, + workflow_state=payload.workflow_state, + is_complete=payload.is_complete, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=True, + ) + audit_from_principal( + session, + principal, + action="campaign.version_autosaved", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/set-step", response_model=CampaignVersionDetailResponse) +def set_version_step( + campaign_id: str, + version_id: str, + payload: CampaignVersionSetStepRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + current_flow=payload.current_flow, + current_step=payload.current_step, + autosave=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/review-state", response_model=CampaignVersionDetailResponse) +def set_version_review_state( + campaign_id: str, + version_id: str, + payload: CampaignReviewStateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = update_campaign_review_state( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + inspection_complete=payload.inspection_complete, + reviewed_message_keys=payload.reviewed_message_keys, + user_id=principal.user.id, + ) + audit_from_principal( + session, + principal, + action="campaign.message_review_updated", + object_type="campaign_version", + object_id=version.id, + details={ + "campaign_id": campaign_id, + "inspection_complete": payload.inspection_complete, + "reviewed_message_count": len(payload.reviewed_message_keys), + }, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse) +def validate_version_partial( + campaign_id: str, + version_id: str, + payload: CampaignPartialValidationRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = get_campaign_version_for_tenant( + session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id + ) + campaign_json = payload.campaign_json if payload and payload.campaign_json is not None else version.raw_json + result = validate_campaign_partial(campaign_json, section=payload.section if payload else None) + audit_from_principal( + session, + principal, + action="campaign.version_partially_validated", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id, "section": result.get("section"), "ok": result.get("ok")}, + commit=True, + ) + return CampaignPartialValidationResponse(**result) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/publish", response_model=CampaignVersionDetailResponse) +def publish_version( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + version = publish_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + user_id=principal.user.id, + ) + audit_from_principal( + session, + principal, + action="campaign.version_user_locked_permanently", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except LockedCampaignVersionError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/versions/{version_id}/validate") +def validate_version( + version_id: str, + payload: ValidateCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")), +): + _get_version_for_principal(session, version_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + try: + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) + if is_user_locked_version(version) or is_version_final_locked(version): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.", + ) + result = validate_campaign_version( + session, + tenant_id=principal.tenant_id, + version_id=version_id, + check_files=payload.check_files if payload else False, + user_id=principal.user.id, + ) + audit_from_principal( + session, + principal, + action="campaign.validated", + object_type="campaign_version", + object_id=version_id, + details={"check_files": payload.check_files if payload else False, "ok": result.get("ok")}, + commit=True, + ) + return result + except HTTPException: + raise + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/versions/{version_id}/build") +def build_version( + version_id: str, + payload: BuildCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")), +): + version = _get_version_for_principal(session, version_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) + try: + result = build_campaign_version( + session, + tenant_id=principal.tenant_id, + version_id=version_id, + write_eml=payload.write_eml if payload else True, + ) + audit_from_principal( + session, + principal, + action="campaign.messages_built", + object_type="campaign_version", + object_id=version_id, + details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")}, + commit=True, + ) + return result + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +def _job_review_key(job: CampaignJob) -> str: + return str(job.entry_id or job.entry_index) + + +def _job_summary_payload( + job: CampaignJob, + *, + reviewed_keys: set[str] | None = None, +) -> dict[str, object]: + review_key = _job_review_key(job) + return { + "id": job.id, + "campaign_version_id": job.campaign_version_id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "subject": job.subject, + "build_status": job.build_status, + "validation_status": job.validation_status, + "queue_status": job.queue_status, + "send_status": job.send_status, + "imap_status": job.imap_status, + "eml_size_bytes": job.eml_size_bytes, + "eml_sha256": job.eml_sha256, + "attempt_count": job.attempt_count, + "last_error": job.last_error, + "queued_at": job.queued_at, + "claimed_at": job.claimed_at, + "smtp_started_at": job.smtp_started_at, + "outcome_unknown_at": job.outcome_unknown_at, + "sent_at": job.sent_at, + "issues_count": len(job.issues_snapshot or []), + "attachment_count": len(job.resolved_attachments or []), + "review_key": review_key, + "reviewed": review_key in reviewed_keys if reviewed_keys is not None else False, + "matched_file_count": sum( + len(item.get("matches") or []) + for item in (job.resolved_attachments or []) + if isinstance(item, dict) + ), + } + + +def _job_detail_payload(job: CampaignJob) -> dict[str, object]: + return { + **_job_summary_payload(job), + "message_id_header": job.message_id_header, + "eml_local_path": job.eml_local_path, + "eml_storage_key": job.eml_storage_key, + "issues": job.issues_snapshot or [], + "attachments": job.resolved_attachments or [], + "resolved_recipients": job.resolved_recipients or {}, + } + + +def _review_metadata( + session: Session, + version: CampaignVersion | None, + base_filters: list[object], +) -> tuple[dict[str, object], set[str]]: + if version is None: + return { + "inspection_complete": False, + "blocking_count": 0, + "required_count": 0, + "reviewed_required_count": 0, + "bulk_acceptable_count": 0, + }, set() + + build_summary = version.build_summary if isinstance(version.build_summary, dict) else {} + build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "") + editor_state = version.editor_state if isinstance(version.editor_state, dict) else {} + review_state = editor_state.get("review_send") if isinstance(editor_state.get("review_send"), dict) else {} + state_token = str(review_state.get("build_token") or "") + state_is_current = bool(build_token and state_token == build_token) + reviewed_keys = { + str(value) + for value in (review_state.get("reviewed_message_keys") or []) + if state_is_current and str(value).strip() + } + + review_rows = ( + session.query( + CampaignJob.entry_id, + CampaignJob.entry_index, + CampaignJob.build_status, + CampaignJob.validation_status, + ) + .filter(*base_filters) + .all() + ) + blocking_count = 0 + required_count = 0 + reviewed_required_count = 0 + bulk_acceptable_count = 0 + for entry_id, entry_index, build_status, validation_status in review_rows: + key = str(entry_id or entry_index) + if build_status != "built" or validation_status == "blocked": + blocking_count += 1 + if validation_status == "needs_review": + required_count += 1 + if key in reviewed_keys: + reviewed_required_count += 1 + elif validation_status in {"warning", "excluded"}: + bulk_acceptable_count += 1 + + return { + "inspection_complete": bool(state_is_current and review_state.get("inspection_complete") is True), + "blocking_count": blocking_count, + "required_count": required_count, + "reviewed_required_count": reviewed_required_count, + "bulk_acceptable_count": bulk_acceptable_count, + }, reviewed_keys + + +def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[str, int]]: + result: dict[str, dict[str, int]] = {} + for field_name in ("build_status", "validation_status", "queue_status", "send_status", "imap_status"): + column = getattr(CampaignJob, field_name) + rows = session.query(column, func.count(CampaignJob.id)).filter(*filters).group_by(column).all() + result[field_name.removesuffix("_status")] = {str(value or "unknown"): int(count) for value, count in rows} + return result + + +@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse) +def list_jobs( + campaign_id: str, + version_id: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + send_status: list[str] | None = Query(default=None), + validation_status: list[str] | None = Query(default=None), + imap_status: list[str] | None = Query(default=None), + query_text: str | None = Query(default=None, alias="q", max_length=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + """Return a lightweight, paginated job list with server-side filters. + + Complete recipients, attachment metadata, issues and attempt history are + available from the separate job-detail endpoint. + """ + + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + base_filters: list[object] = [CampaignJob.campaign_id == campaign.id, CampaignJob.tenant_id == principal.tenant_id] + selected_version: CampaignVersion | None = None + if version_id: + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if version.campaign_id != campaign.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") + selected_version = version + base_filters.append(CampaignJob.campaign_version_id == version.id) + + review_metadata, reviewed_keys = _review_metadata(session, selected_version, base_filters) + filtered = list(base_filters) + if send_status: + filtered.append(CampaignJob.send_status.in_(send_status)) + if validation_status: + filtered.append(CampaignJob.validation_status.in_(validation_status)) + if imap_status: + filtered.append(CampaignJob.imap_status.in_(imap_status)) + if query_text and query_text.strip(): + pattern = f"%{query_text.strip()}%" + filtered.append(or_( + CampaignJob.recipient_email.ilike(pattern), + CampaignJob.subject.ilike(pattern), + CampaignJob.entry_id.ilike(pattern), + )) + + total_unfiltered = int(session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0) + total = int(session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0) + pages = (total + page_size - 1) // page_size if total else 0 + jobs = ( + session.query(CampaignJob) + .filter(*filtered) + .order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return CampaignJobsResponse( + jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs], + page=page, + page_size=page_size, + total=total, + total_unfiltered=total_unfiltered, + pages=pages, + counts=_status_counts(session, base_filters), + filtered_counts=_status_counts(session, filtered), + review=review_metadata, + ) + + +@router.get("/{campaign_id}/jobs/{job_id}", response_model=CampaignJobDetailResponse) +def get_job_detail( + campaign_id: str, + job_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + job = session.get(CampaignJob, job_id) + if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found") + send_attempts = ( + session.query(SendAttempt) + .filter(SendAttempt.job_id == job.id) + .order_by(SendAttempt.attempt_number.asc()) + .all() + ) + imap_attempts = ( + session.query(ImapAppendAttempt) + .filter(ImapAppendAttempt.job_id == job.id) + .order_by(ImapAppendAttempt.attempt_number.asc()) + .all() + ) + return CampaignJobDetailResponse( + job=_job_detail_payload(job), + attempts={ + "smtp": [ + { + "id": attempt.id, + "attempt_number": attempt.attempt_number, + "status": attempt.status, + "claim_token": attempt.claim_token, + "smtp_status_code": attempt.smtp_status_code, + "smtp_response": attempt.smtp_response, + "error_type": attempt.error_type, + "error_message": attempt.error_message, + "started_at": attempt.started_at, + "finished_at": attempt.finished_at, + } + for attempt in send_attempts + ], + "imap": [ + { + "id": attempt.id, + "attempt_number": attempt.attempt_number, + "status": attempt.status, + "folder": attempt.folder, + "error_message": attempt.error_message, + "created_at": attempt.created_at, + "updated_at": attempt.updated_at, + } + for attempt in imap_attempts + ], + }, + ) + + +@router.get("/{campaign_id}/summary") +def campaign_summary( + campaign_id: str, + version_id: str | None = None, + include_jobs: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + if include_jobs: + _require_permission(principal, "campaigns:recipient:read") + """Return dashboard-friendly campaign status counters and summaries.""" + + try: + return generate_campaign_report( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + include_jobs=include_jobs, + ) + except CampaignReportError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.get("/{campaign_id}/report") +def campaign_report( + campaign_id: str, + version_id: str | None = None, + include_jobs: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + if include_jobs: + _require_permission(principal, "campaigns:recipient:read") + """Return the full JSON report for one campaign.""" + + try: + return generate_campaign_report( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + include_jobs=include_jobs, + ) + except CampaignReportError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.get("/{campaign_id}/report/jobs.csv") +def campaign_jobs_csv( + campaign_id: str, + version_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:report:export")), +): + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:export") + """Export per-job campaign status as CSV.""" + + try: + csv_text = generate_jobs_csv( + session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id + ) + except CampaignReportError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + return Response( + content=csv_text, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'}, + ) + + + + +@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse) +def email_campaign_report( + campaign_id: str, + payload: ReportEmailRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")), +): + _get_campaign_for_principal(session, campaign_id, principal) + if payload.include_jobs: + _require_permission(principal, "campaigns:recipient:export") + """Generate a campaign report and send it to one or more email addresses.""" + + try: + result = send_campaign_report_email( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + to=payload.to, + include_jobs=payload.include_jobs, + attach_jobs_csv=payload.attach_jobs_csv, + attach_report_json=payload.attach_report_json, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="report.email_sent" if not payload.dry_run else "report.email_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result.as_dict(), + commit=True, + ) + return ReportEmailResponse(result=result.as_dict()) + except CampaignReportError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except (CampaignReportEmailError, Exception) as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + + + +@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse) +def list_campaign_share_targets( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + users = ( + session.query(User) + .filter(User.tenant_id == principal.tenant_id, User.is_active.is_(True)) + .order_by(User.display_name.asc(), User.email.asc()) + .all() + ) + groups = ( + session.query(Group) + .filter(Group.tenant_id == principal.tenant_id, Group.is_active.is_(True)) + .order_by(Group.name.asc()) + .all() + ) + return CampaignShareTargetsResponse( + users=[CampaignShareTargetItem(id=item.id, name=item.display_name or item.email, secondary=item.email) for item in users], + groups=[CampaignShareTargetItem(id=item.id, name=item.name, secondary=item.slug) for item in groups], + ) + + +@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse) +def list_campaign_shares( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + shares = ( + session.query(CampaignShare) + .filter(CampaignShare.tenant_id == principal.tenant_id, CampaignShare.campaign_id == campaign.id, CampaignShare.revoked_at.is_(None)) + .order_by(CampaignShare.target_type.asc(), CampaignShare.target_id.asc()) + .all() + ) + return CampaignShareListResponse(shares=[CampaignShareItem.model_validate(item) for item in shares]) + + +@router.put("/{campaign_id}/owner", response_model=CampaignResponse) +def update_campaign_owner( + campaign_id: str, + payload: CampaignOwnerUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + if payload.owner_user_id and payload.owner_group_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Choose either a user owner or a group owner, not both") + if payload.owner_user_id: + owner = session.query(User).filter(User.id == payload.owner_user_id, User.tenant_id == principal.tenant_id, User.is_active.is_(True)).one_or_none() + if owner is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found") + if payload.owner_group_id: + group = session.query(Group).filter(Group.id == payload.owner_group_id, Group.tenant_id == principal.tenant_id, Group.is_active.is_(True)).one_or_none() + if group is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found") + campaign.owner_user_id = payload.owner_user_id + campaign.owner_group_id = payload.owner_group_id + session.add(campaign) + audit_from_principal(session, principal, action="campaign.owner_updated", object_type="campaign", object_id=campaign.id, details=payload.model_dump(), commit=True) + return CampaignResponse.model_validate(campaign) + + +@router.post("/{campaign_id}/shares", response_model=CampaignShareItem, status_code=status.HTTP_201_CREATED) +def upsert_campaign_share( + campaign_id: str, + payload: CampaignShareUpsertRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + if payload.target_type == "user": + target = session.query(User).filter(User.id == payload.target_id, User.tenant_id == principal.tenant_id, User.is_active.is_(True)).one_or_none() + else: + target = session.query(Group).filter(Group.id == payload.target_id, Group.tenant_id == principal.tenant_id, Group.is_active.is_(True)).one_or_none() + if target is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found") + share = ( + session.query(CampaignShare) + .filter(CampaignShare.campaign_id == campaign.id, CampaignShare.target_type == payload.target_type, CampaignShare.target_id == payload.target_id) + .one_or_none() + ) + if share is None: + share = CampaignShare( + tenant_id=principal.tenant_id, + campaign_id=campaign.id, + target_type=payload.target_type, + target_id=payload.target_id, + permission=payload.permission, + created_by_user_id=principal.user.id, + ) + else: + share.permission = payload.permission + share.revoked_at = None + session.add(share) + audit_from_principal(session, principal, action="campaign.share_upserted", object_type="campaign", object_id=campaign.id, details=payload.model_dump(), commit=True) + return CampaignShareItem.model_validate(share) + + +@router.delete("/{campaign_id}/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT) +def revoke_campaign_share( + campaign_id: str, + share_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True) + share = session.query(CampaignShare).filter(CampaignShare.id == share_id, CampaignShare.campaign_id == campaign.id, CampaignShare.tenant_id == principal.tenant_id).one_or_none() + if share is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign share not found") + share.revoked_at = utc_now() + session.add(share) + audit_from_principal(session, principal, action="campaign.share_revoked", object_type="campaign", object_id=campaign.id, details={"share_id": share_id}, commit=True) + return None + +# Queue / delivery control ------------------------------------------------- +from govoplan_campaign.backend.schemas import ( + AppendSentRequest, + CampaignActionResponse, + QueueCampaignRequest, + QueueCampaignResponse, + SendCampaignNowRequest, + SendCampaignNowResponse, + MockCampaignSendRequest, + MockCampaignSendResponse, +) +from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, run_mock_campaign_send +from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError +from govoplan_campaign.backend.sending.jobs import ( + QueueingError, + cancel_campaign_jobs, + enqueue_pending_imap_appends, + pause_campaign_jobs, + queue_campaign_jobs, + queue_failed_jobs_for_retry, + queue_unattempted_jobs, + reconcile_job_outcome, + resume_campaign_jobs, + send_campaign_now, +) + + +@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse) +def queue_campaign( + campaign_id: str, + payload: QueueCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + payload = payload or QueueCampaignRequest() + _require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id) + try: + result = queue_campaign_jobs( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + include_warnings=payload.include_warnings, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.queued" if not payload.dry_run else "campaign.queue_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result.as_dict(), + commit=True, + ) + return QueueCampaignResponse(**result.as_dict()) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/jobs/retry", response_model=CampaignActionResponse) +def retry_campaign_jobs( + campaign_id: str, + payload: CampaignRetryJobsRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:retry")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + payload = payload or CampaignRetryJobsRequest() + _require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id) + try: + result = queue_failed_jobs_for_retry( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + job_ids=payload.job_ids or None, + include_permanent=payload.include_permanent, + force_max_attempts=payload.force_max_attempts, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.jobs_retry_queued" if not payload.dry_run else "campaign.jobs_retry_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except (QueueingError, ExecutionSnapshotError) as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse) +def send_unattempted_campaign_jobs( + campaign_id: str, + payload: CampaignSendUnattemptedRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + payload = payload or CampaignSendUnattemptedRequest() + _require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id) + try: + result = queue_unattempted_jobs( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + job_ids=payload.job_ids or None, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.unattempted_jobs_queued" if not payload.dry_run else "campaign.unattempted_jobs_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except (QueueingError, ExecutionSnapshotError) as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/jobs/{job_id}/resolve-outcome", response_model=CampaignActionResponse) +def resolve_campaign_job_outcome( + campaign_id: str, + job_id: str, + payload: CampaignResolveOutcomeRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + try: + result = reconcile_job_outcome( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + job_id=job_id, + decision=payload.decision, + note=payload.note, + ) + audit_from_principal( + session, + principal, + action="campaign.job_outcome_reconciled", + object_type="campaign_job", + object_id=job_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except (QueueingError, ExecutionSnapshotError) as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse) +def mock_send_campaign( + campaign_id: str, + payload: MockCampaignSendRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + """Run a fully visible mock delivery flow without mutating campaign state. + + The route validates and builds the selected version, then optionally records + mock SMTP deliveries and mock IMAP appends. It never talks to the configured + real SMTP/IMAP servers and it does not mark the version sent/final. + """ + + payload = payload or MockCampaignSendRequest() + _require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id) + try: + result = run_mock_campaign_send( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + send=payload.send, + include_warnings=payload.include_warnings, + include_needs_review=payload.include_needs_review, + append_sent=payload.append_sent, + clear_mailbox=payload.clear_mailbox, + check_files=payload.check_files, + ) + audit_from_principal( + session, + principal, + action="campaign.mock_send" if payload.send else "campaign.mock_send_review", + object_type="campaign", + object_id=campaign_id, + details={ + "version_id": result.get("version_id"), + "send_requested": payload.send, + "sent_count": result.get("send", {}).get("sent_count"), + "failed_count": result.get("send", {}).get("failed_count"), + }, + commit=True, + ) + return MockCampaignSendResponse(result=result) + except MockCampaignSendError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse) +def send_campaign_now_endpoint( + campaign_id: str, + payload: SendCampaignNowRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + _require_permission(principal, "campaigns:recipient:read") + """Validate/build/queue and synchronously send a small campaign version. + + This endpoint is intentionally conservative and suitable for a first small + test campaign. Larger campaigns should use the queue/Celery flow. + """ + + payload = payload or SendCampaignNowRequest() + try: + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + version_id = payload.version_id or campaign.current_version_id + if not version_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version") + + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + _require_mail_profile_use_if_needed(principal, version.raw_json if isinstance(version.raw_json, dict) else {}) + validation_result: dict[str, object] | None = version.validation_summary if isinstance(version.validation_summary, dict) else None + build_result: dict[str, object] | None = version.build_summary if isinstance(version.build_summary, dict) else None + if is_user_locked_version(version): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.", + ) + if not version.locked_at or not validation_result or validation_result.get("ok") is not True: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Campaign version must be validated and locked before dry-run or sending.", + ) + if not build_result: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Campaign version must be built before dry-run or sending.", + ) + + result = send_campaign_now( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + include_warnings=payload.include_warnings, + dry_run=payload.dry_run, + use_rate_limit=payload.use_rate_limit, + enqueue_imap_task=payload.enqueue_imap_task, + ).as_dict() + result["validation"] = validation_result + result["build"] = build_result + audit_from_principal( + session, + principal, + action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return SendCampaignNowResponse(result=result) + except HTTPException: + raise + except (CampaignPersistenceError, QueueingError) as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + + +@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse) +def pause_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + result = pause_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) + audit_from_principal(session, principal, action="campaign.paused", object_type="campaign", object_id=campaign_id, details=result, commit=True) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse) +def resume_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) + audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse) +def cancel_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + try: + result = cancel_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) + audit_from_principal(session, principal, action="campaign.cancelled", object_type="campaign", object_id=campaign_id, details=result, commit=True) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse) +def append_sent( + campaign_id: str, + payload: AppendSentRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")), +): + _get_campaign_for_principal(session, campaign_id, principal, write=True) + payload = payload or AppendSentRequest() + _require_campaign_profile_use_if_needed(session, principal, campaign_id) + try: + result = enqueue_pending_imap_appends( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.append_sent_enqueued" if not payload.dry_run else "campaign.append_sent_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +class CampaignAttachmentPreviewRequest(BaseModel): + include_unmatched: bool = True + campaign_json: dict[str, object] | None = None + + +class CampaignAttachmentPreviewResponse(BaseModel): + campaign_id: str + version_id: str + shared_file_count: int + rules: list[dict[str, object]] = Field(default_factory=list) + unused_shared_files: list[dict[str, object]] = Field(default_factory=list) + + +def _file_preview(session: Session, asset) -> dict[str, object]: + version, blob = current_version_and_blob(session, asset) + return { + "id": asset.id, + "version_id": version.id, + "blob_id": blob.id, + "display_path": asset.display_path, + "filename": asset.filename, + "owner_type": asset.owner_type, + "owner_id": asset.owner_user_id if asset.owner_type == "user" else asset.owner_group_id, + "checksum_sha256": blob.checksum_sha256, + "size_bytes": blob.size_bytes, + "content_type": blob.content_type, + } + + +@router.post("/{campaign_id}/versions/{version_id}/attachments/preview", response_model=CampaignAttachmentPreviewResponse) +def preview_campaign_attachments( + campaign_id: str, + version_id: str, + payload: CampaignAttachmentPreviewRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:read")), +): + _get_campaign_for_principal(session, campaign_id, principal) + _require_permission(principal, "campaigns:recipient:read") + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + version = _get_version_for_tenant(session, version_id, principal.tenant_id) + if version.campaign_id != campaign.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") + + payload = payload or CampaignAttachmentPreviewRequest() + raw = payload.campaign_json if isinstance(payload.campaign_json, dict) else version.raw_json + raw = raw if isinstance(raw, dict) else {} + _require_mail_profile_use_if_needed(principal, raw) + + with prepared_campaign_snapshot( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign.id, + raw_json=raw, + include_bytes=False, + prefix="multimailer-managed-preview-", + ) as prepared: + prepared_raw = load_campaign_json(prepared.path) + config = load_campaign_config_from_json(session, tenant_id=principal.tenant_id, raw_json=prepared_raw, campaign_id=campaign.id) + report = resolve_campaign_attachments(config, campaign_file=prepared.path) + rules: list[dict[str, object]] = [] + matched_asset_ids: set[str] = set() + + for entry in report.entries: + for attachment in entry.attachments: + managed_matches = 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]] = [ + { + "id": item["asset_id"], + "version_id": item["version_id"], + "blob_id": item["blob_id"], + "display_path": item["display_path"], + "filename": item["filename"], + "owner_type": item["owner_type"], + "owner_id": item["owner_id"], + "checksum_sha256": item["checksum_sha256"], + "size_bytes": item["size_bytes"], + "content_type": item["content_type"], + } + for item in managed_matches + ] + if not matches: + matches = [ + { + "id": "", + "display_path": match, + "filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1], + "owner_type": "legacy", + "owner_id": "", + } + for match in attachment.matches + ] + rules.append({ + "source": attachment.scope.value, + "entry_index": entry.entry_index, + "entry_id": entry.entry_id, + "index": attachment.index, + "attachment_id": attachment.attachment_id, + "label": attachment.label, + "required": attachment.required, + "pattern": attachment.file_filter, + "base_path_name": attachment.base_path_name, + "base_path": attachment.base_path, + "status": attachment.status.value, + "behavior": attachment.behavior.value if attachment.behavior else None, + "zip_included": attachment.zip_enabled, + "zip_mode": attachment.zip_mode.value, + "zip_archive_id": attachment.zip_archive_id, + "zip_filename": attachment.zip_filename, + "matches": matches, + "match_count": len(matches), + "issues": [issue.model_dump(mode="json") for issue in attachment.issues], + }) + + unused = [asset for asset in prepared.shared_assets if asset.id not in matched_asset_ids] + return CampaignAttachmentPreviewResponse( + campaign_id=campaign.id, + version_id=version.id, + shared_file_count=len(prepared.shared_assets), + rules=rules, + unused_shared_files=[_file_preview(session, asset) for asset in unused] if payload.include_unmatched else [], + ) + diff --git a/src/govoplan_campaign/backend/schema/campaign.schema.json b/src/govoplan_campaign/backend/schema/campaign.schema.json new file mode 100644 index 0000000..9e23179 --- /dev/null +++ b/src/govoplan_campaign/backend/schema/campaign.schema.json @@ -0,0 +1,1277 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://multimailer.local/schema/campaign.schema.json", + "title": "MultiMailer Campaign", + "type": "object", + "required": [ + "version", + "campaign", + "template", + "entries" + ], + "properties": { + "version": { + "type": "string", + "const": "1.0" + }, + "campaign": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "draft", + "test", + "send" + ], + "default": "draft" + } + }, + "additionalProperties": false + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "integer", + "double", + "date", + "password" + ], + "default": "string" + }, + "label": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + }, + "can_override": { + "type": "boolean", + "default": true, + "description": "Whether recipient/entry field values may override the global value for this field." + } + }, + "additionalProperties": false + }, + "default": [] + }, + "global_values": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "server": { + "type": "object", + "properties": { + "mail_profile_id": { + "type": "string" + }, + "inherit_smtp_credentials": { + "type": "boolean", + "default": true + }, + "inherit_imap_credentials": { + "type": "boolean", + "default": true + }, + "smtp": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "security": { + "type": "string", + "enum": [ + "plain", + "tls", + "starttls" + ], + "default": "starttls" + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "default": 30 + } + }, + "additionalProperties": false + }, + "imap": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "security": { + "type": "string", + "enum": [ + "plain", + "tls", + "starttls" + ], + "default": "tls" + }, + "sent_folder": { + "type": "string", + "default": "auto" + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "default": 30 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "recipients": { + "type": "object", + "properties": { + "from": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "maxItems": 1, + "default": [] + }, + { + "$ref": "#/$defs/recipient" + }, + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "null" + } + ], + "description": "Canonical form is an array containing at most one From mailbox. A single recipient object remains accepted for backward compatibility." + }, + "allow_individual_from": { + "type": "boolean", + "default": false + }, + "to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_to": { + "type": "boolean", + "default": false + }, + "cc": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_cc": { + "type": "boolean", + "default": false + }, + "bcc": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_bcc": { + "type": "boolean", + "default": false + }, + "reply_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_reply_to": { + "type": "boolean", + "default": false + }, + "bounce_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_bounce_to": { + "type": "boolean", + "default": false + }, + "disposition_notification_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_disposition_notification_to": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "default": {} + }, + "template": { + "oneOf": [ + { + "type": "object", + "required": [ + "subject" + ], + "properties": { + "subject": { + "type": "string" + }, + "text": { + "type": [ + "string", + "null" + ] + }, + "html": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source" + ], + "properties": { + "source": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "const": "files" + }, + "subject_path": { + "type": "string" + }, + "text_path": { + "type": "string" + }, + "html_path": { + "type": "string" + }, + "encoding": { + "type": "string", + "default": "utf-8" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "attachments": { + "type": "object", + "properties": { + "base_path": { + "type": "string", + "default": ".", + "description": "Legacy single campaign-level attachment base path. Used when attachments.base_paths is absent." + }, + "base_paths": { + "type": "array", + "items": { + "$ref": "#/$defs/attachment_base_path" + }, + "default": [], + "description": "Named selectable attachment base paths. New WebUI campaigns use these instead of the legacy single base_path." + }, + "allow_individual": { + "type": "boolean", + "default": false + }, + "send_without_attachments": { + "type": "boolean", + "default": true, + "description": "Legacy compatibility flag. Prefer validation_policy and per-config missing_behavior for new campaigns." + }, + "zip": { + "$ref": "#/$defs/zip_collection_config", + "description": "Recipient-level ZIP defaults. Matching attachment rules may inherit, include or exclude themselves." + }, + "global": { + "type": "array", + "items": { + "$ref": "#/$defs/attachment_config" + }, + "default": [] + }, + "missing_behavior": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + }, + "ambiguous_behavior": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + } + }, + "additionalProperties": false, + "default": { + "base_path": ".", + "base_paths": [], + "global": [], + "zip": { + "enabled": false, + "archives": [] + } + } + }, + "entries": { + "oneOf": [ + { + "type": "object", + "required": [ + "inline" + ], + "properties": { + "inline": { + "type": "array", + "items": { + "$ref": "#/$defs/entry" + } + }, + "defaults": { + "$ref": "#/$defs/entry" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "mapping" + ], + "properties": { + "source": { + "$ref": "#/$defs/source" + }, + "mapping": { + "type": "object", + "description": "Internal campaign path -> source column/key. Examples: to.0.email, fields.number, attachments.0.file_filter.", + "additionalProperties": { + "type": "string" + } + }, + "defaults": { + "$ref": "#/$defs/entry" + } + }, + "additionalProperties": false + } + ] + }, + "validation_policy": { + "type": "object", + "properties": { + "missing_required_attachment": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + }, + "missing_optional_attachment": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "warn" + }, + "ambiguous_attachment_match": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + }, + "ignore_empty_fields": { + "type": "boolean", + "default": false, + "description": "Render unresolved/empty template placeholders as an empty string instead of keeping them and raising a template error." + }, + "missing_email": { + "type": "string", + "enum": [ + "block", + "drop" + ], + "default": "block" + }, + "template_error": { + "type": "string", + "enum": [ + "block", + "drop" + ], + "default": "block" + }, + "inactive_entry": { + "type": "string", + "enum": [ + "drop", + "block", + "warn" + ], + "default": "drop" + }, + "unsent_attachment_files": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "warn", + "description": "Behavior when a base path with unsent_warning contains files that are not attached to any message." + } + }, + "additionalProperties": false, + "default": { + "ignore_empty_fields": false, + "unsent_attachment_files": "warn" + } + }, + "delivery": { + "type": "object", + "properties": { + "rate_limit": { + "type": "object", + "properties": { + "messages_per_minute": { + "type": "integer", + "minimum": 1, + "default": 5 + }, + "concurrency": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + "additionalProperties": false + }, + "imap_append_sent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "folder": { + "type": "string", + "default": "auto" + } + }, + "additionalProperties": false + }, + "retry": { + "type": "object", + "properties": { + "max_attempts": { + "type": "integer", + "minimum": 1, + "default": 3 + }, + "backoff_seconds": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [ + 60, + 300, + 900 + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "default": {} + }, + "status_tracking": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "initial_build_status": { + "type": "string", + "enum": [ + "built", + "build_failed" + ], + "default": "built" + }, + "initial_send_status": { + "type": "string", + "enum": [ + "draft", + "queued" + ], + "default": "draft" + } + }, + "additionalProperties": false, + "default": { + "enabled": true + } + } + }, + "additionalProperties": false, + "$defs": { + "recipient": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "to", + "cc", + "bcc", + "reply_to", + "bounce_to", + "disposition_notification_to" + ], + "default": "to" + } + }, + "additionalProperties": false + }, + "attachment_config": { + "type": "object", + "required": [ + "base_dir", + "file_filter" + ], + "properties": { + "id": { + "type": "string", + "description": "Optional stable ID for UI/status references." + }, + "label": { + "type": "string" + }, + "base_path_id": { + "type": [ + "string", + "null" + ], + "description": "Stable reference to attachments.base_paths[].id. Preferred over path matching when multiple managed spaces use the same logical path." + }, + "base_dir": { + "type": "string", + "description": "Selected attachment base path for current WebUI campaigns, or a directory relative to attachments.base_path for legacy campaigns." + }, + "file_filter": { + "type": "string", + "description": "Glob/filter expression, rendered with global/local fields before matching." + }, + "include_subdirs": { + "type": "boolean", + "default": false + }, + "required": { + "type": "boolean", + "default": true + }, + "allow_multiple": { + "type": "boolean", + "default": false, + "description": "Legacy compatibility flag. Current UI treats wildcard file_filter patterns as multi-match-capable automatically." + }, + "missing_behavior": { + "type": [ + "string", + "null" + ], + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn", + null + ], + "default": null, + "description": "Optional per-rule override. Null or omitted inherits from validation_policy." + }, + "ambiguous_behavior": { + "type": [ + "string", + "null" + ], + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn", + null + ], + "default": null, + "description": "Optional per-rule override. Null or omitted inherits from validation_policy." + }, + "zip": { + "$ref": "#/$defs/zip_rule_config" + }, + "type": { + "description": "Legacy UI helper; ignored by backend. Direct files are represented as plain file_filter patterns.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "entry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "active": { + "type": "boolean", + "default": true + }, + "from": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "maxItems": 1, + "default": [] + }, + { + "$ref": "#/$defs/recipient" + }, + { + "type": "object", + "maxProperties": 0 + }, + { + "type": "null" + } + ], + "description": "Canonical form is an array containing at most one From mailbox. A single recipient object remains accepted for backward compatibility." + }, + "merge_from": { + "type": "boolean", + "default": false, + "description": "Deprecated compatibility field. Recipient-specific From always overrides the campaign From value and never merges." + }, + "to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "merge_to": { + "type": "boolean", + "default": true, + "description": "When individual to values are allowed, merge global and recipient values instead of overriding the global values." + }, + "combine_to": { + "type": "boolean", + "default": true, + "description": "Deprecated compatibility alias for merge_to. New campaign JSON should use merge_*." + }, + "cc": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "merge_cc": { + "type": "boolean", + "default": true, + "description": "When individual cc values are allowed, merge global and recipient values instead of overriding the global values." + }, + "combine_cc": { + "type": "boolean", + "default": true, + "description": "Deprecated compatibility alias for merge_cc. New campaign JSON should use merge_*." + }, + "bcc": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "merge_bcc": { + "type": "boolean", + "default": true, + "description": "When individual bcc values are allowed, merge global and recipient values instead of overriding the global values." + }, + "combine_bcc": { + "type": "boolean", + "default": true, + "description": "Deprecated compatibility alias for merge_bcc. New campaign JSON should use merge_*." + }, + "reply_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "merge_reply_to": { + "type": "boolean", + "default": true, + "description": "When individual reply-to values are allowed, merge global and recipient values instead of overriding the global values." + }, + "combine_reply_to": { + "type": "boolean", + "default": true, + "description": "Deprecated compatibility alias for merge_reply_to. New campaign JSON should use merge_*." + }, + "bounce_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "merge_bounce_to": { + "type": "boolean", + "default": true, + "description": "When individual bounce-to values are allowed, merge global and recipient values instead of overriding the global values." + }, + "combine_bounce_to": { + "type": "boolean", + "default": true, + "description": "Deprecated compatibility alias for merge_bounce_to. New campaign JSON should use merge_*." + }, + "disposition_notification_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "merge_disposition_notification_to": { + "type": "boolean", + "default": true, + "description": "When individual disposition-notification-to values are allowed, merge global and recipient values instead of overriding the global values." + }, + "combine_disposition_notification_to": { + "type": "boolean", + "default": true, + "description": "Deprecated compatibility alias for merge_disposition_notification_to. New campaign JSON should use merge_*." + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/attachment_config" + }, + "default": [] + }, + "combine_attachments": { + "type": "boolean", + "default": true + }, + "fields": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "last_sent": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ], + "format": "email" + } + }, + "additionalProperties": false + }, + "source": { + "type": "object", + "required": [ + "type", + "path" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "csv", + "json" + ] + }, + "path": { + "type": "string" + }, + "delimiter": { + "type": "string", + "default": ";" + }, + "encoding": { + "type": "string", + "default": "utf-8" + }, + "has_header": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + }, + "attachment_base_path": { + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "id": { + "type": "string", + "description": "Optional stable ID for UI/status references." + }, + "name": { + "type": "string", + "description": "Display name for this selectable attachment base path." + }, + "path": { + "type": "string", + "default": ".", + "description": "Base path relative to the campaign file unless absolute." + }, + "allow_individual": { + "type": "boolean", + "default": false, + "description": "Whether recipient-level attachments may use this base path." + }, + "source": { + "type": [ + "string", + "null" + ], + "description": "Legacy UI compatibility value. Ignored by the backend." + }, + "unsent_warning": { + "type": "boolean", + "default": false, + "description": "Warn according to validation_policy.unsent_attachment_files if files in this source are not attached to any built message." + } + }, + "additionalProperties": false + }, + "zip_config": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "mode": { + "type": "string", + "enum": [ + "inherit", + "include", + "exclude" + ], + "default": "inherit", + "description": "Per-attachment archive behavior. Campaign-level mode is accepted for schema compatibility but ignored." + }, + "filename_template": { + "type": [ + "string", + "null" + ], + "description": "Recipient-level ZIP filename. Campaign/global/local placeholders are supported." + }, + "password_mode": { + "type": "string", + "enum": [ + "none", + "direct", + "field", + "template" + ], + "default": "none" + }, + "password": { + "type": [ + "string", + "null" + ], + "description": "Fixed ZIP password when password_mode is direct." + }, + "password_field": { + "type": [ + "string", + "null" + ], + "description": "Campaign field containing the recipient-specific password when password_mode is field." + }, + "password_template": { + "type": [ + "string", + "null" + ], + "description": "Legacy/advanced password template when password_mode is template." + }, + "method": { + "type": "string", + "enum": [ + "zip_standard", + "aes" + ], + "default": "aes" + } + }, + "additionalProperties": false, + "default": { + "enabled": false, + "mode": "inherit", + "password_mode": "none", + "method": "aes" + } + }, + "zip_archive_config": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string", + "default": "attachments.zip" + }, + "standard": { + "type": "boolean", + "default": false + }, + "password_enabled": { + "type": "boolean", + "default": false + }, + "password_field": { + "type": [ + "string", + "null" + ] + }, + "password_scope": { + "type": "string", + "enum": [ + "local", + "global" + ], + "default": "local" + }, + "method": { + "type": "string", + "enum": [ + "zip_standard", + "aes" + ], + "default": "aes" + }, + "password_mode": { + "type": [ + "string", + "null" + ], + "enum": [ + "none", + "direct", + "field", + "template", + null + ] + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "password_template": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "zip_collection_config": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "archives": { + "type": "array", + "items": { + "$ref": "#/$defs/zip_archive_config" + }, + "default": [] + }, + "filename_template": { + "type": [ + "string", + "null" + ] + }, + "password_mode": { + "type": "string", + "enum": [ + "none", + "direct", + "field", + "template" + ], + "default": "none" + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "password_field": { + "type": [ + "string", + "null" + ] + }, + "password_template": { + "type": [ + "string", + "null" + ] + }, + "method": { + "type": "string", + "enum": [ + "zip_standard", + "aes" + ], + "default": "aes" + } + }, + "additionalProperties": false, + "default": { + "enabled": false, + "archives": [] + } + }, + "zip_rule_config": { + "type": "object", + "properties": { + "archive_id": { + "type": "string", + "default": "inherit", + "description": "inherit, exclude, or a configured archive id" + }, + "enabled": { + "type": "boolean", + "default": false + }, + "mode": { + "type": "string", + "enum": [ + "inherit", + "include", + "exclude" + ], + "default": "inherit" + }, + "filename_template": { + "type": [ + "string", + "null" + ] + }, + "password_mode": { + "type": "string", + "enum": [ + "none", + "direct", + "field", + "template" + ], + "default": "none" + }, + "password": { + "type": [ + "string", + "null" + ] + }, + "password_field": { + "type": [ + "string", + "null" + ] + }, + "password_template": { + "type": [ + "string", + "null" + ] + }, + "method": { + "type": "string", + "enum": [ + "zip_standard", + "aes" + ], + "default": "aes" + } + }, + "additionalProperties": false, + "default": { + "archive_id": "inherit" + } + } + } +} diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py new file mode 100644 index 0000000..d3a56f2 --- /dev/null +++ b/src/govoplan_campaign/backend/schemas.py @@ -0,0 +1,555 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from govoplan_mail.backend.config import ImapConfig, SmtpConfig + + +class CampaignCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + config: dict[str, Any] + source_filename: str | None = None + source_base_path: str | None = None + + + + +class CampaignUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + external_id: str | None = None + name: str | None = None + status: str | None = None + description: str | None = None + + +class CampaignCreateMinimalRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + external_id: str + name: str + description: str | None = None + current_flow: str = "create" + current_step: str = "basics" + + +class CampaignVersionUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_json: dict[str, Any] | None = None + current_flow: str | None = None + current_step: str | None = None + workflow_state: str | None = None + is_complete: bool | None = None + editor_state: dict[str, Any] | None = None + source_filename: str | None = None + source_base_path: str | None = None + + +class CampaignVersionSetStepRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + current_flow: str | None = None + current_step: str + + +class CampaignReviewStateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + inspection_complete: bool = False + reviewed_message_keys: list[str] = Field(default_factory=list) + + +class CampaignPartialValidationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_json: dict[str, Any] | None = None + section: str | None = None + + +class CampaignVersionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + campaign_id: str + version_number: int + schema_version: str + source_filename: str | None = None + source_base_path: str | None = None + workflow_state: str = "editing" + current_flow: str = "manual" + current_step: str | None = None + is_complete: bool = False + editor_state: dict[str, Any] = Field(default_factory=dict) + autosaved_at: datetime | None = None + published_at: datetime | None = None + locked_at: datetime | None = None + locked_by_user_id: str | None = None + user_lock_state: Literal["temporary", "permanent"] | None = None + user_locked_at: datetime | None = None + user_locked_by_user_id: str | None = None + created_at: datetime + updated_at: datetime + validation_summary: dict[str, Any] | None = None + build_summary: dict[str, Any] | None = None + execution_snapshot_hash: str | None = None + execution_snapshot_at: datetime | None = None + + +class CampaignVersionDetailResponse(CampaignVersionResponse): + raw_json: dict[str, Any] + + +class CampaignPartialValidationResponse(BaseModel): + ok: bool + section: str | None = None + error_count: int + warning_count: int + info_count: int + issues: list[dict[str, Any]] + + +class CampaignResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + external_id: str + name: str + description: str | None = None + status: str + current_version_id: str | None = None + owner_user_id: str | None = None + owner_group_id: str | None = None + created_at: datetime + updated_at: datetime + + +class CampaignCreateResponse(BaseModel): + campaign: CampaignResponse + version: CampaignVersionResponse + + +class CampaignListResponse(BaseModel): + campaigns: list[CampaignResponse] + + +class CampaignShareItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + campaign_id: str + target_type: Literal["user", "group"] + target_id: str + permission: Literal["read", "write"] = "read" + revoked_at: datetime | None = None + + +class CampaignShareListResponse(BaseModel): + shares: list[CampaignShareItem] + + +class CampaignShareTargetItem(BaseModel): + id: str + name: str + secondary: str | None = None + + +class CampaignShareTargetsResponse(BaseModel): + users: list[CampaignShareTargetItem] = Field(default_factory=list) + groups: list[CampaignShareTargetItem] = Field(default_factory=list) + + +class CampaignShareUpsertRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + target_type: Literal["user", "group"] + target_id: str + permission: Literal["read", "write"] = "read" + + +class CampaignOwnerUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + owner_user_id: str | None = None + owner_group_id: str | None = None + + +class CampaignJobsResponse(BaseModel): + jobs: list[dict[str, Any]] + page: int = 1 + page_size: int = 50 + total: int = 0 + total_unfiltered: int = 0 + pages: int = 0 + counts: dict[str, dict[str, int]] = Field(default_factory=dict) + filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict) + review: dict[str, Any] = Field(default_factory=dict) + + +class CampaignJobDetailResponse(BaseModel): + job: dict[str, Any] + attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) + + +class CampaignRetryJobsRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + version_id: str | None = None + job_ids: list[str] = Field(default_factory=list) + include_permanent: bool = False + force_max_attempts: bool = False + enqueue_celery: bool = True + dry_run: bool = False + + +class CampaignSendUnattemptedRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + version_id: str | None = None + job_ids: list[str] = Field(default_factory=list) + enqueue_celery: bool = True + dry_run: bool = False + + +class CampaignResolveOutcomeRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + decision: Literal["smtp_accepted", "not_sent"] + note: str | None = None + + +class ValidateCampaignRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + check_files: bool = False + + +class BuildCampaignRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + write_eml: bool = True + + +class MailSmtpTestRequest(SmtpConfig): + """SMTP settings supplied directly from the WebUI mail settings form.""" + + +class MailImapTestRequest(ImapConfig): + """IMAP settings supplied directly from the WebUI mail settings form.""" + + enabled: bool = True + + + + +MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"] + + +class MailCredentialPolicyPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + inherit: bool | None = None + allow_override: bool | None = None + + +class MailProfilePolicyPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + allowed_profile_ids: list[str] = Field(default_factory=list) + allow_user_profiles: bool | None = None + allow_group_profiles: bool | None = None + allow_campaign_profiles: bool | None = None + smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) + imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) + whitelist: dict[str, list[str]] = Field(default_factory=dict) + blacklist: dict[str, list[str]] = Field(default_factory=dict) + + +class MailProfilePolicyUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload) + + +class MailProfilePolicyResponse(BaseModel): + scope_type: MailProfileScope + scope_id: str | None = None + policy: dict[str, Any] + effective_policy: dict[str, Any] | None = None + + +class MailServerProfileCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + slug: str | None = Field(default=None, max_length=100) + description: str | None = None + is_active: bool = True + scope_type: MailProfileScope = "tenant" + scope_id: str | None = None + smtp: SmtpConfig + imap: ImapConfig | None = None + + +class MailServerProfileUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, max_length=255) + slug: str | None = Field(default=None, max_length=100) + description: str | None = None + is_active: bool | None = None + smtp: SmtpConfig | None = None + imap: ImapConfig | None = None + clear_imap: bool = False + + +class MailServerProfileResponse(BaseModel): + id: str + tenant_id: str | None = None + scope_type: MailProfileScope = "tenant" + scope_id: str | None = None + name: str + slug: str + description: str | None = None + is_active: bool + smtp: dict[str, Any] + imap: dict[str, Any] | None = None + smtp_password_configured: bool = False + imap_password_configured: bool = False + created_at: datetime + updated_at: datetime + + +class MailServerProfileListResponse(BaseModel): + profiles: list[MailServerProfileResponse] = Field(default_factory=list) + + +class MailConnectionTestResponse(BaseModel): + ok: bool + protocol: Literal["smtp", "imap"] + host: str | None = None + port: int | None = None + security: str | None = None + message: str + details: dict[str, Any] = Field(default_factory=dict) + + +class MailImapFolderResponse(BaseModel): + name: str + flags: list[str] = Field(default_factory=list) + + +class MailImapFolderListResponse(BaseModel): + ok: bool + protocol: Literal["imap"] = "imap" + host: str | None = None + port: int | None = None + security: str | None = None + message: str + folders: list[MailImapFolderResponse] = Field(default_factory=list) + detected_sent_folder: str | None = None + details: dict[str, Any] = Field(default_factory=dict) + + +class ApiKeyCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + scopes: list[str] = Field(default_factory=list) + + +class ApiKeyCreateResponse(BaseModel): + id: str + name: str + prefix: str + scopes: list[str] + secret: str + + +class QueueCampaignRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + version_id: str | None = None + include_warnings: bool = True + enqueue_celery: bool = True + dry_run: bool = False + + +class QueueCampaignResponse(BaseModel): + campaign_id: str + version_id: str + queued_count: int + skipped_count: int + blocked_count: int + enqueued_count: int + dry_run: bool = False + + +class SendCampaignNowRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + version_id: str | None = None + include_warnings: bool = True + check_files: bool = False + validate_before_send: bool = False + build_before_send: bool = False + dry_run: bool = False + use_rate_limit: bool = True + enqueue_imap_task: bool = False + + +class SendCampaignNowResponse(BaseModel): + result: dict[str, Any] + + +class MockCampaignSendRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + version_id: str | None = None + send: bool = False + include_warnings: bool = True + include_needs_review: bool = False + append_sent: bool = True + clear_mailbox: bool = False + check_files: bool = False + + +class MockCampaignSendResponse(BaseModel): + result: dict[str, Any] + + +class AppendSentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + enqueue_celery: bool = True + dry_run: bool = False + + +class CampaignActionResponse(BaseModel): + result: dict[str, Any] + +class ReportEmailRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + to: list[str] + version_id: str | None = None + include_jobs: bool = False + attach_jobs_csv: bool = True + attach_report_json: bool = False + dry_run: bool = False + + +class ReportEmailResponse(BaseModel): + result: dict[str, Any] + + +class AuditLogItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + user_id: str | None = None + api_key_id: str | None = None + action: str + object_type: str | None = None + object_id: str | None = None + details: dict[str, Any] | None = None + created_at: datetime + + +class AuditLogListResponse(BaseModel): + items: list[AuditLogItemResponse] + + + +class LoginRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + email: str + password: str + # Kept optional for backwards compatibility and future tenant-switch login flows. + # The WebUI no longer sends it. If omitted, the backend resolves the user by email. + tenant_slug: str | None = None + + + + +class SwitchTenantRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + tenant_id: str + + +class TenantInfo(BaseModel): + id: str + slug: str + name: str + is_active: bool = True + default_locale: str = "en" + + +class TenantMembershipInfo(TenantInfo): + roles: list[str] = Field(default_factory=list) + is_active: bool = True + + +class UserInfo(BaseModel): + id: str + account_id: str + email: str + # Global account identity used by the title bar and account settings. + display_name: str | None = None + # Optional tenant-local alias used in tenant administration and ownership. + tenant_display_name: str | None = None + is_tenant_admin: bool = False + password_reset_required: bool = False + + +class ProfileUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, max_length=255) + tenant_display_name: str | None = Field(default=None, max_length=255) + + +class RoleInfo(BaseModel): + id: str + slug: str + name: str + permissions: list[str] = Field(default_factory=list) + level: Literal["tenant", "system"] = "tenant" + + +class GroupInfo(BaseModel): + id: str + slug: str + name: str + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_at: datetime + user: UserInfo + # Backwards-compatible alias for the active tenant. + tenant: TenantInfo + active_tenant: TenantInfo + tenants: list[TenantMembershipInfo] = Field(default_factory=list) + scopes: list[str] + roles: list[RoleInfo] = Field(default_factory=list) + groups: list[GroupInfo] = Field(default_factory=list) + + +class MeResponse(BaseModel): + user: UserInfo + # Backwards-compatible alias for the active tenant. + tenant: TenantInfo + active_tenant: TenantInfo + tenants: list[TenantMembershipInfo] = Field(default_factory=list) + scopes: list[str] + roles: list[RoleInfo] = Field(default_factory=list) + groups: list[GroupInfo] = Field(default_factory=list) diff --git a/src/govoplan_campaign/backend/sending/__init__.py b/src/govoplan_campaign/backend/sending/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/sending/execution.py b/src/govoplan_campaign/backend/sending/execution.py new file mode 100644 index 0000000..6525a91 --- /dev/null +++ b/src/govoplan_campaign/backend/sending/execution.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from typing import Any, Iterable + +from pydantic import BaseModel, ConfigDict +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 + +SNAPSHOT_VERSION = "3" + + +class ExecutionSnapshotError(RuntimeError): + pass + + +class ExecutionSnapshot(BaseModel): + """Immutable delivery inputs for one built campaign version. + + Rendered messages and attachment evidence remain normalized in + ``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the + mutable transport/runtime configuration and cryptographically binds it to + the build and the exact set of persisted jobs without duplicating all + recipient data into one large JSON value. + """ + + model_config = ConfigDict(extra="forbid") + + snapshot_version: str = SNAPSHOT_VERSION + campaign_version_id: str + campaign_json_sha256: str + created_at: str + build_token: str | None = None + built_at: str | None = None + job_count: int = 0 + queueable_job_count: int = 0 + job_manifest_sha256: str | None = None + effective_policy_sha256: str | None = None + smtp_config_fingerprint: str | None = None + imap_config_fingerprint: str | None = None + smtp: SmtpConfig + imap: ImapConfig | None = None + delivery: DeliveryConfig + + +def _canonical_json(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + + +def _sha256(value: Any) -> str: + return hashlib.sha256(_canonical_json(value)).hexdigest() + + +def snapshot_hash(payload: dict[str, Any]) -> str: + return _sha256(payload) + + +def _transport_fingerprint(config: SmtpConfig | ImapConfig | None) -> str | None: + if config is None: + return None + payload = config.model_dump(mode="json") + # The fingerprint is safe to expose in reports. It identifies the effective + # account/transport settings without incorporating or revealing the secret. + if "password" in payload: + payload["password"] = "" if payload.get("password") else None + return _sha256(payload) + + +def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpConfig | ImapConfig | None: + if config is None: + return None + payload = config.model_dump(mode="json") + payload["password"] = None + if isinstance(config, SmtpConfig): + return SmtpConfig.model_validate(payload) + return ImapConfig.model_validate(payload) + + +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 + password = config.get("password") if isinstance(config, dict) else None + if password is None: + return None + return str(password) + + +def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any]: + server = raw_json.get("server") if isinstance(raw_json, dict) else None + return server if isinstance(server, dict) else {} + + +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 {}) + 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) + except MailProfileError as exc: + raise ExecutionSnapshotError(str(exc)) from exc + + +def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> SmtpConfig: + payload = snapshot.smtp.model_dump(mode="json") + if not payload.get("password"): + server = _server_from_campaign_json(version.raw_json) + profile = _profile_for_version(session, version) + if profile is not None: + 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"): + return SmtpConfig.model_validate(profile_payload) + return SmtpConfig.model_validate(apply_campaign_credentials(profile_payload, server, "smtp")) + payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp") + return SmtpConfig.model_validate(payload) + + +def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> ImapConfig | None: + server = _server_from_campaign_json(version.raw_json) + if snapshot.imap is None: + profile = _profile_for_version(session, version) + if profile is None: + return None + imap = 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"): + return ImapConfig.model_validate(imap_payload) + return ImapConfig.model_validate(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) + 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"): + return ImapConfig.model_validate(imap_payload) + return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap")) + payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap") + return ImapConfig.model_validate(payload) + + +def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str: + return _sha256( + { + "validation_policy": raw_json.get("validation_policy"), + "policy": raw_json.get("policy"), + "delivery": delivery.model_dump(mode="json"), + "attachment_defaults": (raw_json.get("attachments") or {}).get("defaults") + if isinstance(raw_json.get("attachments"), dict) + else None, + } + ) + + +def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str: + """Hash the immutable per-message execution records in stable order.""" + + payload: list[dict[str, Any]] = [] + for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)): + payload.append( + { + "job_id": job.id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "subject": job.subject, + "message_id_header": job.message_id_header, + "eml_size_bytes": job.eml_size_bytes, + "eml_sha256": job.eml_sha256, + "build_status": job.build_status, + "validation_status": job.validation_status, + "resolved_recipients_sha256": _sha256(job.resolved_recipients or {}), + "resolved_attachments_sha256": _sha256(job.resolved_attachments or []), + "issues_sha256": _sha256(job.issues_snapshot or []), + } + ) + return _sha256(payload) + + +def create_execution_snapshot( + version: CampaignVersion, + *, + smtp: SmtpConfig, + imap: ImapConfig | None, + delivery: DeliveryConfig, + jobs: Iterable[CampaignJob] = (), + build_summary: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], str]: + raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} + job_list = list(jobs) + summary = build_summary if isinstance(build_summary, dict) else {} + queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value} + redacted_smtp = _redacted_transport_config(smtp) + redacted_imap = _redacted_transport_config(imap) + assert isinstance(redacted_smtp, SmtpConfig) + assert redacted_imap is None or isinstance(redacted_imap, ImapConfig) + payload = ExecutionSnapshot( + campaign_version_id=version.id, + campaign_json_sha256=_sha256(raw_json), + build_token=str(summary.get("build_token") or "") or None, + built_at=str(summary.get("built_at") or "") or None, + job_count=len(job_list), + queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses), + job_manifest_sha256=job_manifest_hash(job_list) if job_list else None, + effective_policy_sha256=_policy_fingerprint(raw_json, delivery), + smtp_config_fingerprint=_transport_fingerprint(smtp), + imap_config_fingerprint=_transport_fingerprint(imap), + created_at=datetime.now(timezone.utc).isoformat(), + smtp=redacted_smtp, + imap=redacted_imap, + delivery=delivery, + ).model_dump(mode="json") + return payload, snapshot_hash(payload) + + +def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> ExecutionSnapshot: + """Return a validated snapshot, creating one for pre-migration builds. + + New builds create the snapshot after persisting their jobs. The fallback is + intentionally limited to legacy built versions so they can be operated + without a manual data migration. + """ + + if isinstance(version.execution_snapshot, dict): + snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot) + expected = snapshot_hash(snapshot.model_dump(mode="json")) + if version.execution_snapshot_hash and version.execution_snapshot_hash != expected: + raise ExecutionSnapshotError("Execution snapshot checksum mismatch") + return snapshot + + from govoplan_campaign.backend.persistence.campaigns import load_version_config + + _, _, config = load_version_config(session, version.id) + if not config.server.smtp: + raise ExecutionSnapshotError("Campaign has no SMTP configuration") + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.campaign_version_id == version.id) + .order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc()) + .all() + ) + if not jobs: + 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, + delivery=config.delivery, + jobs=jobs, + build_summary=version.build_summary if isinstance(version.build_summary, dict) else {}, + ) + version.execution_snapshot = payload + version.execution_snapshot_hash = digest + version.execution_snapshot_at = datetime.now(timezone.utc) + session.add(version) + session.flush() + return ExecutionSnapshot.model_validate(payload) + + +def clear_execution_snapshot(version: CampaignVersion) -> None: + version.execution_snapshot = None + version.execution_snapshot_hash = None + version.execution_snapshot_at = None diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py new file mode 100644 index 0000000..29f5a88 --- /dev/null +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -0,0 +1,1281 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from email import policy +from email.parser import BytesParser +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignJob, + CampaignStatus, + CampaignVersion, + CampaignVersionWorkflowState, + JobBuildStatus, + JobImapStatus, + JobQueueStatus, + JobSendStatus, + JobValidationStatus, + ImapAppendAttempt, + 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 + + +class QueueingError(RuntimeError): + pass + + +class SendJobError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class QueueCampaignResult: + campaign_id: str + version_id: str + queued_count: int + skipped_count: int + blocked_count: int + enqueued_count: int + dry_run: bool = False + + def as_dict(self) -> dict[str, Any]: + return { + "campaign_id": self.campaign_id, + "version_id": self.version_id, + "queued_count": self.queued_count, + "skipped_count": self.skipped_count, + "blocked_count": self.blocked_count, + "enqueued_count": self.enqueued_count, + "dry_run": self.dry_run, + } + + +@dataclass(frozen=True, slots=True) +class SendCampaignNowResult: + campaign_id: str + version_id: str + attempted_count: int + sent_count: int + failed_count: int + outcome_unknown_count: int + skipped_count: int + dry_run: bool = False + results: list[dict[str, Any]] | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "campaign_id": self.campaign_id, + "version_id": self.version_id, + "attempted_count": self.attempted_count, + "sent_count": self.sent_count, + "failed_count": self.failed_count, + "outcome_unknown_count": self.outcome_unknown_count, + "skipped_count": self.skipped_count, + "dry_run": self.dry_run, + "results": self.results or [], + } + + +@dataclass(frozen=True, slots=True) +class SendJobResult: + job_id: str + status: str + attempt_number: int + dry_run: bool = False + message: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "status": self.status, + "attempt_number": self.attempt_number, + "dry_run": self.dry_run, + "message": self.message, + } + + +@dataclass(frozen=True, slots=True) +class AppendSentResult: + job_id: str + status: str + attempt_number: int + dry_run: bool = False + folder: str | None = None + message: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "status": self.status, + "attempt_number": self.attempt_number, + "dry_run": self.dry_run, + "folder": self.folder, + "message": self.message, + } + + +QUEUEABLE_VALIDATION_STATUSES = { + JobValidationStatus.READY.value, + JobValidationStatus.WARNING.value, +} +SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value} +AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value} +EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value} + + +def _version_user_lock_state(version: CampaignVersion) -> str | None: + state = getattr(version, "user_lock_state", None) + if state in {"temporary", "permanent"}: + return state + return "permanent" if version.published_at else None + + +def _version_is_user_locked(version: CampaignVersion) -> bool: + return _version_user_lock_state(version) is not None + + +def _version_is_validated_and_locked(version: CampaignVersion) -> bool: + validation = version.validation_summary if isinstance(version.validation_summary, dict) else {} + return bool(version.locked_at and validation.get("ok") is True and not _version_is_user_locked(version)) + + +def _ensure_version_validated_and_locked(version: CampaignVersion) -> None: + state = _version_user_lock_state(version) + if state == "temporary": + raise QueueingError("This version has a temporary user lock. Unlock it before queueing, dry-run or sending.") + if state == "permanent": + raise QueueingError("This version is permanently user-locked. Create an editable copy instead.") + if not _version_is_validated_and_locked(version): + raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _get_campaign_for_tenant(session: Session, *, campaign_id: str, tenant_id: str) -> Campaign: + campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none() + if not campaign: + raise QueueingError(f"Campaign not found or not accessible: {campaign_id}") + return campaign + + +def _get_version_for_campaign( + session: Session, + campaign: Campaign, + version_id: str | None = None, +) -> CampaignVersion: + wanted = version_id or campaign.current_version_id + if not wanted: + raise QueueingError("Campaign has no selected version") + version = session.get(CampaignVersion, wanted) + if not version or version.campaign_id != campaign.id: + raise QueueingError(f"Campaign version not found or not part of campaign: {wanted}") + return version + + +def _get_current_version(session: Session, campaign: Campaign, version_id: str | None = None) -> CampaignVersion: + version = _get_version_for_campaign(session, campaign, version_id) + if campaign.current_version_id != version.id: + raise QueueingError( + "Historical campaign versions cannot start a new execution. Use the explicit retry or reconciliation actions for an existing execution." + ) + return version + + +def _celery_enqueue_send_job(job_id: str) -> None: + from govoplan_core.celery_app import celery + + celery.send_task("multimailer.send_email", args=[job_id], queue="send_email") + + +def _celery_enqueue_append_sent_job(job_id: str) -> None: + from govoplan_core.celery_app import celery + + celery.send_task("multimailer.append_sent", args=[job_id], queue="append_sent") + + +def queue_campaign_jobs( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + enqueue_celery: bool = True, + include_warnings: bool = True, + dry_run: bool = False, +) -> QueueCampaignResult: + """Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks.""" + + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + version = _get_current_version(session, campaign, version_id=version_id) + _ensure_version_validated_and_locked(version) + try: + ensure_execution_snapshot(session, version) + except ExecutionSnapshotError as exc: + raise QueueingError(str(exc)) from exc + + allowed_validation = {JobValidationStatus.READY.value} + if include_warnings: + allowed_validation.add(JobValidationStatus.WARNING.value) + + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + if not jobs: + raise QueueingError("Campaign version has no jobs. Build messages before queueing.") + + queued: list[CampaignJob] = [] + skipped_count = 0 + blocked_count = 0 + for job in jobs: + if job.send_status in SMTP_ACCEPTED_STATUSES | { + JobSendStatus.CLAIMED.value, + JobSendStatus.SENDING.value, + JobSendStatus.OUTCOME_UNKNOWN.value, + JobSendStatus.FAILED_TEMPORARY.value, + JobSendStatus.FAILED_PERMANENT.value, + JobSendStatus.CANCELLED.value, + }: + # Initial queueing never doubles as retry/reconciliation. Those + # states require the dedicated explicit actions below. + skipped_count += 1 + continue + if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value}: + skipped_count += 1 + continue + if job.build_status != JobBuildStatus.BUILT.value or job.validation_status not in allowed_validation: + blocked_count += 1 + continue + if not job.eml_local_path and not job.eml_storage_key: + job.last_error = "Job has no generated EML path/storage key. Rebuild with write_eml enabled before queueing." + blocked_count += 1 + continue + + queued.append(job) + if not dry_run: + job.queue_status = JobQueueStatus.QUEUED.value + job.send_status = JobSendStatus.QUEUED.value + job.queued_at = _utcnow() + job.claimed_at = None + job.claim_token = None + job.smtp_started_at = None + job.outcome_unknown_at = None + job.last_error = None + session.add(job) + + if not dry_run: + if queued: + campaign.status = CampaignStatus.QUEUED.value + version.workflow_state = CampaignVersionWorkflowState.QUEUED.value + if version.locked_at is None: + version.locked_at = _utcnow() + session.add(version) + session.add(campaign) + session.commit() + + enqueued_count = 0 + if enqueue_celery and not dry_run: + for job in queued: + _celery_enqueue_send_job(job.id) + enqueued_count += 1 + + return QueueCampaignResult( + campaign_id=campaign.id, + version_id=version.id, + queued_count=len(queued), + skipped_count=skipped_count, + blocked_count=blocked_count, + enqueued_count=enqueued_count, + dry_run=dry_run, + ) + + +def send_campaign_now( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + include_warnings: bool = True, + dry_run: bool = False, + use_rate_limit: bool = True, + enqueue_imap_task: bool = False, +) -> SendCampaignNowResult: + """Queue and send a small campaign synchronously. + + This is intended for WebUI test/small-campaign flows. Large campaigns can + still use queue_campaign_jobs with Celery workers. + """ + + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + version = _get_current_version(session, campaign, version_id=version_id) + + queue_result = queue_campaign_jobs( + session, + tenant_id=tenant_id, + campaign_id=campaign.id, + version_id=version.id, + include_warnings=include_warnings, + enqueue_celery=False, + dry_run=dry_run, + ) + if dry_run: + return SendCampaignNowResult( + campaign_id=campaign.id, + version_id=version.id, + attempted_count=0, + sent_count=0, + failed_count=0, + outcome_unknown_count=0, + skipped_count=queue_result.skipped_count + queue_result.blocked_count, + dry_run=True, + results=[queue_result.as_dict()], + ) + + jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_version_id == version.id, + CampaignJob.queue_status == JobQueueStatus.QUEUED.value, + CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]), + ) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + + results: list[dict[str, Any]] = [] + sent_count = 0 + failed_count = 0 + outcome_unknown_count = 0 + skipped_after_queue = 0 + for job in jobs: + try: + result = send_campaign_job( + session, + job_id=job.id, + dry_run=False, + use_rate_limit=use_rate_limit, + enqueue_imap_task=enqueue_imap_task, + ) + result_dict = result.as_dict() + results.append(result_dict) + if result.status in {JobSendStatus.SMTP_ACCEPTED.value, "already_accepted"}: + sent_count += 1 + elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value: + outcome_unknown_count += 1 + else: + skipped_after_queue += 1 + except Exception as exc: # keep sending other jobs and return per-job details + failed_count += 1 + results.append({"job_id": job.id, "status": "failed", "message": str(exc)}) + + return SendCampaignNowResult( + campaign_id=campaign.id, + version_id=version.id, + attempted_count=len(jobs), + sent_count=sent_count, + failed_count=failed_count, + outcome_unknown_count=outcome_unknown_count, + skipped_count=queue_result.skipped_count + queue_result.blocked_count + skipped_after_queue, + dry_run=False, + results=results, + ) + + +def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.queue_status == JobQueueStatus.QUEUED.value, + CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]), + ) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + for job in jobs: + _celery_enqueue_send_job(job.id) + return len(jobs) + + +def pause_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + changed = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.queue_status == JobQueueStatus.QUEUED.value, + ) + .update({CampaignJob.queue_status: JobQueueStatus.PAUSED.value}, synchronize_session=False) + ) + if changed: + campaign.status = CampaignStatus.READY_TO_QUEUE.value + session.add(campaign) + session.commit() + return {"campaign_id": campaign.id, "paused_count": int(changed)} + + +def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True) -> dict[str, Any]: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.queue_status == JobQueueStatus.PAUSED.value, + ) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + for job in jobs: + job.queue_status = JobQueueStatus.QUEUED.value + job.send_status = JobSendStatus.QUEUED.value + session.add(job) + if jobs: + campaign.status = CampaignStatus.QUEUED.value + session.add(campaign) + session.commit() + + enqueued_count = 0 + if enqueue_celery: + for job in jobs: + _celery_enqueue_send_job(job.id) + enqueued_count += 1 + return {"campaign_id": campaign.id, "resumed_count": len(jobs), "enqueued_count": enqueued_count} + + +def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id) + .all() + ) + cancelled_count = 0 + protected_count = 0 + version_ids: set[str] = set() + for job in jobs: + version_ids.add(job.campaign_version_id) + if job.send_status in SMTP_ACCEPTED_STATUSES | { + JobSendStatus.OUTCOME_UNKNOWN.value, + JobSendStatus.CLAIMED.value, + JobSendStatus.SENDING.value, + }: + protected_count += 1 + continue + if job.send_status != JobSendStatus.CANCELLED.value: + job.queue_status = JobQueueStatus.CANCELLED.value + job.send_status = JobSendStatus.CANCELLED.value + job.claim_token = None + session.add(job) + cancelled_count += 1 + for version_id in version_ids: + _update_campaign_after_job(session, campaign.id, version_id) + session.commit() + return { + "campaign_id": campaign.id, + "cancelled_count": cancelled_count, + "protected_count": protected_count, + "campaign_status": campaign.status, + } + + +def _eligible_jobs_for_explicit_action( + session: Session, + *, + tenant_id: str, + campaign: Campaign, + version: CampaignVersion, + job_ids: list[str] | None, +) -> list[CampaignJob]: + query = session.query(CampaignJob).filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.campaign_version_id == version.id, + ) + if job_ids: + query = query.filter(CampaignJob.id.in_(job_ids)) + return query.order_by(CampaignJob.entry_index.asc()).all() + + +def queue_failed_jobs_for_retry( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + job_ids: list[str] | None = None, + include_permanent: bool = False, + force_max_attempts: bool = False, + enqueue_celery: bool = True, + dry_run: bool = False, +) -> dict[str, Any]: + """Explicitly queue failed jobs; never includes uncertain or accepted jobs.""" + + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + version = _get_version_for_campaign(session, campaign, version_id=version_id) + _ensure_version_validated_and_locked(version) + snapshot = ensure_execution_snapshot(session, version) + allowed = {JobSendStatus.FAILED_TEMPORARY.value} + if include_permanent: + allowed.add(JobSendStatus.FAILED_PERMANENT.value) + + selected: list[CampaignJob] = [] + skipped: list[dict[str, str]] = [] + for job in _eligible_jobs_for_explicit_action( + session, + tenant_id=tenant_id, + campaign=campaign, + version=version, + job_ids=job_ids, + ): + if job.send_status not in allowed: + skipped.append({"job_id": job.id, "reason": f"status {job.send_status} is not an explicit retry candidate"}) + continue + if not force_max_attempts and job.attempt_count >= snapshot.delivery.retry.max_attempts: + skipped.append({"job_id": job.id, "reason": "configured maximum attempts reached"}) + continue + selected.append(job) + if not dry_run: + job.queue_status = JobQueueStatus.QUEUED.value + job.send_status = JobSendStatus.QUEUED.value + job.queued_at = _utcnow() + job.claimed_at = None + job.claim_token = None + job.smtp_started_at = None + job.outcome_unknown_at = None + session.add(job) + + if not dry_run: + if selected: + campaign.status = CampaignStatus.QUEUED.value + version.workflow_state = CampaignVersionWorkflowState.QUEUED.value + session.add(campaign) + session.add(version) + session.commit() + + enqueued = 0 + if enqueue_celery and not dry_run: + for job in selected: + _celery_enqueue_send_job(job.id) + enqueued += 1 + return { + "campaign_id": campaign.id, + "version_id": version.id, + "action": "retry_failed", + "selected_count": len(selected), + "enqueued_count": enqueued, + "skipped": skipped, + "dry_run": dry_run, + } + + +def queue_unattempted_jobs( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + job_ids: list[str] | None = None, + enqueue_celery: bool = True, + dry_run: bool = False, +) -> dict[str, Any]: + """Explicitly queue built jobs that have never started an SMTP attempt.""" + + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + version = _get_version_for_campaign(session, campaign, version_id=version_id) + _ensure_version_validated_and_locked(version) + ensure_execution_snapshot(session, version) + selected: list[CampaignJob] = [] + skipped: list[dict[str, str]] = [] + for job in _eligible_jobs_for_explicit_action( + session, + tenant_id=tenant_id, + campaign=campaign, + version=version, + job_ids=job_ids, + ): + eligible = ( + job.attempt_count == 0 + and job.send_status in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value} + and job.build_status == JobBuildStatus.BUILT.value + and job.validation_status in QUEUEABLE_VALIDATION_STATUSES + ) + if not eligible: + skipped.append({"job_id": job.id, "reason": "job is not an unattempted queueable message"}) + continue + selected.append(job) + if not dry_run: + job.queue_status = JobQueueStatus.QUEUED.value + job.send_status = JobSendStatus.QUEUED.value + job.queued_at = _utcnow() + job.claimed_at = None + job.claim_token = None + job.smtp_started_at = None + job.outcome_unknown_at = None + job.last_error = None + session.add(job) + if not dry_run: + if selected: + campaign.status = CampaignStatus.QUEUED.value + version.workflow_state = CampaignVersionWorkflowState.QUEUED.value + session.add(campaign) + session.add(version) + session.commit() + enqueued = 0 + if enqueue_celery and not dry_run: + for job in selected: + _celery_enqueue_send_job(job.id) + enqueued += 1 + return { + "campaign_id": campaign.id, + "version_id": version.id, + "action": "send_unattempted", + "selected_count": len(selected), + "enqueued_count": enqueued, + "skipped": skipped, + "dry_run": dry_run, + } + + +def reconcile_job_outcome( + session: Session, + *, + tenant_id: str, + campaign_id: str, + job_id: str, + decision: str, + note: str | None = None, +) -> dict[str, Any]: + """Record an operator decision for an uncertain/incomplete worker state. + + ``smtp_accepted`` protects the message from retry and enables IMAP append. + ``not_sent`` converts it into a failed-temporary candidate; retry remains a + separate explicit action. + """ + + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + job = session.get(CampaignJob, job_id) + if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.id: + raise QueueingError("Campaign job not found or not accessible") + if job.send_status not in { + JobSendStatus.OUTCOME_UNKNOWN.value, + JobSendStatus.SENDING.value, + JobSendStatus.CLAIMED.value, + }: + raise QueueingError(f"Job status {job.send_status} does not require reconciliation") + version = _get_version_for_campaign(session, campaign, version_id=job.campaign_version_id) + snapshot = ensure_execution_snapshot(session, version) + now = _utcnow() + attempt = _unfinished_attempt(session, job) + if decision == "smtp_accepted": + job.send_status = JobSendStatus.SMTP_ACCEPTED.value + job.queue_status = JobQueueStatus.DRAFT.value + job.sent_at = job.sent_at or now + job.outcome_unknown_at = None + 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) + attempt_status = "reconciled_smtp_accepted" + elif decision == "not_sent": + job.send_status = JobSendStatus.FAILED_TEMPORARY.value + job.queue_status = JobQueueStatus.DRAFT.value + job.outcome_unknown_at = None + job.claim_token = None + job.last_error = note or "Operator confirmed that SMTP did not accept this message." + attempt_status = "reconciled_not_sent" + else: + raise QueueingError("decision must be 'smtp_accepted' or 'not_sent'") + if attempt: + attempt.status = attempt_status + attempt.finished_at = now + attempt.error_type = "OperatorReconciliation" + attempt.error_message = job.last_error + session.add(attempt) + session.add(job) + _update_campaign_after_job(session, campaign.id, version.id) + session.commit() + return { + "campaign_id": campaign.id, + "version_id": version.id, + "job_id": job.id, + "decision": decision, + "send_status": job.send_status, + "imap_status": job.imap_status, + "note": job.last_error, + } + + +def _verify_eml_evidence(job: CampaignJob, payload: bytes) -> None: + if job.eml_size_bytes is not None and len(payload) != job.eml_size_bytes: + raise SendJobError( + f"Generated EML size mismatch for job {job.id}: expected {job.eml_size_bytes}, got {len(payload)}" + ) + if job.eml_sha256: + actual = hashlib.sha256(payload).hexdigest() + if actual != job.eml_sha256: + raise SendJobError( + f"Generated EML SHA-256 mismatch for job {job.id}; rebuild the campaign version before delivery" + ) + if job.message_id_header: + parsed = BytesParser(policy=policy.default).parsebytes(payload) + message_id = parsed.get("Message-ID") + parsed_message_id = str(message_id) if message_id else None + if parsed_message_id != job.message_id_header: + raise SendJobError( + f"Generated EML Message-ID mismatch for job {job.id}; rebuild the campaign version before delivery" + ) + + +def _load_eml_bytes_for_job(job: CampaignJob) -> bytes: + if job.eml_local_path: + path = Path(job.eml_local_path) + if not path.exists(): + raise SendJobError(f"Generated EML file does not exist: {path}") + payload = path.read_bytes() + _verify_eml_evidence(job, payload) + return payload + raise SendJobError("Only local EML paths are supported for sending in this implementation step") + + +def _load_eml_for_job(job: CampaignJob): + return BytesParser(policy=policy.default).parsebytes(_load_eml_bytes_for_job(job)) + + +def _addresses_from_job(job: CampaignJob, field: str) -> list[str]: + data = job.resolved_recipients or {} + values = data.get(field) or [] + return [item.get("email") for item in values if isinstance(item, dict) and item.get("email")] + + +def _sender_from_job(job: CampaignJob, snapshot: ExecutionSnapshot) -> str: + data = job.resolved_recipients or {} + bounce_to = _addresses_from_job(job, "bounce_to") + if bounce_to: + return bounce_to[0] + from_data = data.get("from") if isinstance(data, dict) else None + if isinstance(from_data, dict) and from_data.get("email"): + return from_data["email"] + if snapshot.smtp.username: + return snapshot.smtp.username + raise SmtpConfigurationError("No envelope sender could be determined") + + +def _from_header_from_job(job: CampaignJob, snapshot: ExecutionSnapshot) -> str | None: + data = job.resolved_recipients or {} + from_data = data.get("from") if isinstance(data, dict) else None + if isinstance(from_data, dict) and from_data.get("email"): + return str(from_data["email"]) + return snapshot.smtp.username + + +def _recipients_from_job(job: CampaignJob) -> list[str]: + recipients: list[str] = [] + for field in ["to", "cc", "bcc"]: + recipients.extend(_addresses_from_job(job, field)) + # Preserve order while de-duplicating. + return list(dict.fromkeys(recipients)) + + +def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None: + """Atomically claim a queued job and return the claim token. + + A duplicate task can observe CLAIMED/SENDING but cannot acquire a second + claim. CLAIMED is deliberately not timed out automatically: an operator can + safely release it after checking worker state, while an automatic timeout + could race a slow worker. + """ + + claim_token = str(uuid4()) + changed = ( + session.query(CampaignJob) + .filter( + CampaignJob.id == job.id, + CampaignJob.queue_status == JobQueueStatus.QUEUED.value, + CampaignJob.send_status == JobSendStatus.QUEUED.value, + ) + .update( + { + CampaignJob.queue_status: JobQueueStatus.SENDING.value, + CampaignJob.send_status: JobSendStatus.CLAIMED.value, + CampaignJob.claimed_at: _utcnow(), + CampaignJob.claim_token: claim_token, + CampaignJob.last_error: None, + }, + synchronize_session=False, + ) + ) + session.commit() + session.expire_all() + return claim_token if changed == 1 else None + + +def _record_attempt_start(session: Session, job: CampaignJob, claim_token: str) -> SendAttempt: + now = _utcnow() + attempt = SendAttempt( + job_id=job.id, + attempt_number=job.attempt_count + 1, + status="smtp_in_progress", + claim_token=claim_token, + started_at=now, + ) + job.attempt_count += 1 + job.queue_status = JobQueueStatus.SENDING.value + job.send_status = JobSendStatus.SENDING.value + job.smtp_started_at = now + job.outcome_unknown_at = None + job.last_error = None + session.add(attempt) + session.add(job) + session.commit() + return attempt + + +def _unfinished_attempt(session: Session, job: CampaignJob) -> SendAttempt | None: + return ( + session.query(SendAttempt) + .filter(SendAttempt.job_id == job.id, SendAttempt.finished_at.is_(None)) + .order_by(SendAttempt.attempt_number.desc()) + .first() + ) + + +def mark_job_outcome_unknown(session: Session, job: CampaignJob, *, reason: str) -> SendJobResult: + """Freeze an uncertain SMTP result and prohibit automatic redelivery.""" + + now = _utcnow() + attempt = _unfinished_attempt(session, job) + if attempt: + attempt.status = "outcome_unknown" + attempt.finished_at = now + attempt.error_type = "OutcomeUnknown" + attempt.error_message = reason + session.add(attempt) + job.queue_status = JobQueueStatus.DRAFT.value + job.send_status = JobSendStatus.OUTCOME_UNKNOWN.value + job.outcome_unknown_at = now + job.last_error = reason + session.add(job) + _update_campaign_after_job(session, job.campaign_id, job.campaign_version_id) + session.commit() + return SendJobResult( + job_id=job.id, + status=JobSendStatus.OUTCOME_UNKNOWN.value, + attempt_number=job.attempt_count, + message=reason, + ) + + +def _update_campaign_after_job(session: Session, campaign_id: str, version_id: str | None = None) -> None: + session.flush() + campaign = session.get(Campaign, campaign_id) + if not campaign: + return + base_filters = [CampaignJob.campaign_id == campaign_id] + if version_id: + base_filters.append(CampaignJob.campaign_version_id == version_id) + + counts = { + status: session.query(CampaignJob).filter(*base_filters, CampaignJob.send_status == status).count() + for status in {item.value for item in JobSendStatus} + } + accepted = sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES) + unknown = counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0) + failed = counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0) + active = ( + counts.get(JobSendStatus.QUEUED.value, 0) + + counts.get(JobSendStatus.CLAIMED.value, 0) + + counts.get(JobSendStatus.SENDING.value, 0) + ) + cancelled = counts.get(JobSendStatus.CANCELLED.value, 0) + # Blocked/excluded build rows are reportable but are not delivery attempts. + # Only a built, queueable message counts as an unattempted part of an + # execution when deriving a partial outcome. + not_started = ( + session.query(CampaignJob) + .filter( + *base_filters, + CampaignJob.send_status == JobSendStatus.NOT_QUEUED.value, + CampaignJob.build_status == JobBuildStatus.BUILT.value, + CampaignJob.validation_status.in_(list(QUEUEABLE_VALIDATION_STATUSES)), + ) + .count() + ) + + version = session.get(CampaignVersion, version_id) if version_id else None + if active: + campaign.status = CampaignStatus.SENDING.value + if version: + version.workflow_state = CampaignVersionWorkflowState.SENDING.value + elif accepted and (failed or unknown or cancelled or not_started): + campaign.status = CampaignStatus.PARTIALLY_COMPLETED.value + if version: + version.workflow_state = CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value + elif unknown: + campaign.status = CampaignStatus.OUTCOME_UNKNOWN.value + if version: + version.workflow_state = CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value + elif failed: + campaign.status = CampaignStatus.FAILED.value + if version: + version.workflow_state = CampaignVersionWorkflowState.FAILED.value + elif accepted: + campaign.status = CampaignStatus.SENT.value + if version: + version.workflow_state = CampaignVersionWorkflowState.COMPLETED.value + elif cancelled: + campaign.status = CampaignStatus.CANCELLED.value + if version: + version.workflow_state = CampaignVersionWorkflowState.CANCELLED.value + + if version and (accepted or unknown): + if version.locked_at is None: + version.locked_at = _utcnow() + session.add(version) + session.add(campaign) + + +def send_campaign_job( + session: Session, + *, + job_id: str, + dry_run: bool = False, + use_rate_limit: bool = True, + enqueue_imap_task: bool = False, +) -> SendJobResult: + job = session.get(CampaignJob, job_id) + if not job: + raise SendJobError(f"Job not found: {job_id}") + if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value: + return SendJobResult(job_id=job_id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run) + if job.queue_status == JobQueueStatus.PAUSED.value: + return SendJobResult(job_id=job_id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run) + if job.send_status in SMTP_ACCEPTED_STATUSES: + return SendJobResult(job_id=job_id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run) + if job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value: + return SendJobResult( + job_id=job_id, + status=JobSendStatus.OUTCOME_UNKNOWN.value, + attempt_number=job.attempt_count, + dry_run=dry_run, + message="SMTP outcome is unresolved; reconcile it before any retry.", + ) + if job.send_status == JobSendStatus.SENDING.value: + return mark_job_outcome_unknown( + session, + job, + reason="A delivery task resumed while the previous SMTP attempt was still marked in progress. Automatic resend was stopped.", + ) + if job.send_status == JobSendStatus.CLAIMED.value: + return SendJobResult( + job_id=job_id, + status="already_claimed", + attempt_number=job.attempt_count, + dry_run=dry_run, + message="Another worker has claimed this job, or a stale pre-SMTP claim requires operator review.", + ) + if job.queue_status != JobQueueStatus.QUEUED.value or job.send_status != JobSendStatus.QUEUED.value: + raise SendJobError(f"Job is not explicitly queued for delivery: queue={job.queue_status}, send={job.send_status}") + + version = session.get(CampaignVersion, job.campaign_version_id) + if not version: + raise SendJobError("Campaign version not found") + try: + snapshot = ensure_execution_snapshot(session, version) + except ExecutionSnapshotError as exc: + raise SendJobError(str(exc)) from exc + + message_bytes = _load_eml_bytes_for_job(job) + envelope_from = _sender_from_job(job, snapshot) + envelope_recipients = _recipients_from_job(job) + if not envelope_recipients: + raise SmtpConfigurationError("No envelope recipients could be determined") + + if dry_run: + return SendJobResult( + job_id=job.id, + status="dry_run", + attempt_number=job.attempt_count, + dry_run=True, + message=f"Would send to {len(envelope_recipients)} recipient(s) from {envelope_from}", + ) + + claim_token = _claim_job_for_sending(session, job) + if claim_token is None: + current = session.get(CampaignJob, job.id) + if not current: + raise SendJobError(f"Job disappeared while claiming: {job.id}") + if current.send_status == JobSendStatus.SENDING.value: + return mark_job_outcome_unknown( + session, + current, + reason="A duplicate/redelivered task found an unfinished SMTP attempt. Automatic resend was stopped.", + ) + return SendJobResult( + job_id=current.id, + status="not_claimed", + attempt_number=current.attempt_count, + message=f"Job is no longer queueable: {current.send_status}", + ) + + job = session.get(CampaignJob, job.id) + assert job is not None + 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, + ) + attempt = _record_attempt_start(session, job, claim_token) + try: + smtp_config = runtime_smtp_config(session, version, snapshot) + assert_mail_policy_allows_send( + session, + tenant_id=job.tenant_id, + campaign_id=job.campaign_id, + smtp=smtp_config, + envelope_sender=envelope_from, + from_header=_from_header_from_job(job, snapshot), + recipients=envelope_recipients, + ) + result = send_email_bytes( + message_bytes, + smtp_config=smtp_config, + envelope_from=envelope_from, + envelope_recipients=envelope_recipients, + ) + if result.accepted_count <= 0: + raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False) + refused_warning = None + if result.refused_recipients: + refused_warning = ( + f"SMTP accepted {result.accepted_count}/{len(result.envelope_recipients)} envelope recipient(s); " + f"refused recipients: {json.dumps(result.refused_recipients, default=str, sort_keys=True)}" + ) + attempt.finished_at = _utcnow() + attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value + attempt.smtp_response = json.dumps(asdict(result), default=str) + job.queue_status = JobQueueStatus.DRAFT.value + job.send_status = JobSendStatus.SMTP_ACCEPTED.value + job.sent_at = _utcnow() + job.claim_token = None + job.outcome_unknown_at = None + if snapshot.delivery.imap_append_sent.enabled: + job.imap_status = JobImapStatus.PENDING.value + else: + job.imap_status = JobImapStatus.NOT_REQUESTED.value + job.last_error = refused_warning + mark_job_attachment_uses_sent(session, job) + session.add(attempt) + session.add(job) + _update_campaign_after_job(session, job.campaign_id, job.campaign_version_id) + session.commit() + if enqueue_imap_task and job.imap_status == JobImapStatus.PENDING.value: + _celery_enqueue_append_sent_job(job.id) + return SendJobResult( + job_id=job.id, + status=JobSendStatus.SMTP_ACCEPTED.value, + attempt_number=attempt.attempt_number, + message=refused_warning, + ) + + except SmtpSendError as exc: + if getattr(exc, "outcome_unknown", False): + attempt.status = JobSendStatus.OUTCOME_UNKNOWN.value + attempt.finished_at = _utcnow() + attempt.error_type = exc.__class__.__name__ + attempt.error_message = str(exc) + session.add(attempt) + job.claim_token = None + session.add(job) + session.flush() + return mark_job_outcome_unknown(session, job, reason=str(exc)) + + attempt.finished_at = _utcnow() + attempt.error_type = exc.__class__.__name__ + attempt.error_message = str(exc) + retryable = bool(getattr(exc, "temporary", False)) + job.last_error = str(exc) + job.queue_status = JobQueueStatus.DRAFT.value + job.send_status = JobSendStatus.FAILED_TEMPORARY.value if retryable else JobSendStatus.FAILED_PERMANENT.value + job.claim_token = None + attempt.status = job.send_status + session.add(attempt) + session.add(job) + _update_campaign_after_job(session, job.campaign_id, job.campaign_version_id) + session.commit() + raise + except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc: + attempt.finished_at = _utcnow() + attempt.error_type = exc.__class__.__name__ + attempt.error_message = str(exc) + attempt.status = JobSendStatus.FAILED_PERMANENT.value + job.last_error = str(exc) + job.queue_status = JobQueueStatus.DRAFT.value + job.send_status = JobSendStatus.FAILED_PERMANENT.value + job.claim_token = None + session.add(attempt) + session.add(job) + _update_campaign_after_job(session, job.campaign_id, job.campaign_version_id) + session.commit() + raise + + +def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt: + existing_count = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + attempt = ImapAppendAttempt( + job_id=job.id, + attempt_number=existing_count + 1, + status="running", + ) + job.imap_status = JobImapStatus.PENDING.value + job.last_error = None + session.add(attempt) + session.add(job) + session.commit() + return attempt + + +def _effective_imap_folder(snapshot: ExecutionSnapshot) -> str: + delivery_folder = snapshot.delivery.imap_append_sent.folder + if delivery_folder and delivery_folder != "auto": + return delivery_folder + if snapshot.imap and snapshot.imap.sent_folder and snapshot.imap.sent_folder != "auto": + return snapshot.imap.sent_folder + return "auto" + + +def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult: + """Append one successfully sent job's exact EML to the configured IMAP Sent folder.""" + + job = session.get(CampaignJob, job_id) + if not job: + raise SendJobError(f"Job not found: {job_id}") + if job.send_status not in SMTP_ACCEPTED_STATUSES: + return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="SMTP has not accepted this job") + if job.imap_status == JobImapStatus.NOT_REQUESTED.value: + return AppendSentResult(job_id=job_id, status="not_requested", attempt_number=0, dry_run=dry_run) + if job.imap_status == JobImapStatus.APPENDED.value: + attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + return AppendSentResult(job_id=job_id, status="already_appended", attempt_number=attempts, dry_run=dry_run) + if job.imap_status == JobImapStatus.SKIPPED.value: + attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + return AppendSentResult(job_id=job_id, status="skipped", attempt_number=attempts, dry_run=dry_run) + + version = session.get(CampaignVersion, job.campaign_version_id) + if not version: + raise SendJobError("Campaign version not found") + try: + snapshot = ensure_execution_snapshot(session, version) + except ExecutionSnapshotError as exc: + raise SendJobError(str(exc)) from exc + if not snapshot.delivery.imap_append_sent.enabled: + job.imap_status = JobImapStatus.NOT_REQUESTED.value + session.add(job) + 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: + job.imap_status = JobImapStatus.SKIPPED.value + job.last_error = "IMAP append requested, but the execution snapshot has no enabled 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) + + message_bytes = _load_eml_bytes_for_job(job) + folder = _effective_imap_folder(snapshot) + if dry_run: + attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + return AppendSentResult( + job_id=job.id, + status="dry_run", + attempt_number=attempts, + dry_run=True, + folder=folder, + message=f"Would append {len(message_bytes)} bytes to IMAP folder {folder!r}", + ) + + attempt = _record_imap_attempt_start(session, job) + try: + 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( + message_bytes, + imap_config=imap_config, + folder=None if folder == "auto" else folder, + ) + attempt.status = "appended" + attempt.folder = result.folder + job.imap_status = JobImapStatus.APPENDED.value + job.last_error = None + mark_job_attachment_uses_sent(session, job) + session.add(attempt) + session.add(job) + session.commit() + return AppendSentResult(job_id=job.id, status="appended", attempt_number=attempt.attempt_number, folder=result.folder) + except (MailProfileError, ImapConfigurationError, ImapAppendError, SendJobError, OSError) as exc: + attempt.status = "failed" + attempt.folder = None if folder == "auto" else folder + attempt.error_message = str(exc) + job.imap_status = JobImapStatus.FAILED.value + job.last_error = str(exc) + session.add(attempt) + session.add(job) + session.commit() + 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]: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.send_status.in_(list(SMTP_ACCEPTED_STATUSES)), + CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]), + ) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + if not dry_run and enqueue_celery: + for job in jobs: + _celery_enqueue_append_sent_job(job.id) + return { + "campaign_id": campaign.id, + "pending_count": len(jobs), + "enqueued_count": 0 if dry_run or not enqueue_celery else len(jobs), + "dry_run": dry_run, + } + +def next_retry_delay(snapshot: ExecutionSnapshot, attempt_count: int) -> int: + delays = snapshot.delivery.retry.backoff_seconds or [60] + index = max(0, min(attempt_count - 1, len(delays) - 1)) + return int(delays[index]) diff --git a/src/govoplan_campaign/backend/services/__init__.py b/src/govoplan_campaign/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/govoplan_campaign/backend/services/attachment_matching.py b/src/govoplan_campaign/backend/services/attachment_matching.py new file mode 100644 index 0000000..379eb79 --- /dev/null +++ b/src/govoplan_campaign/backend/services/attachment_matching.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_campaign.backend.domain.campaign import MailAttachmentConfig + + +def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]: + directory = base_path / config.base_dir + if not directory.exists(): + return [] + iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter) + return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0) diff --git a/src/govoplan_campaign/backend/services/campaign_executor.py b/src/govoplan_campaign/backend/services/campaign_executor.py new file mode 100644 index 0000000..3020593 --- /dev/null +++ b/src/govoplan_campaign/backend/services/campaign_executor.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import smtplib +from email.message import EmailMessage +from pathlib import Path +from typing import Iterable + +from govoplan_campaign.backend.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity +from govoplan_campaign.backend.domain.queue import MailQueue +from govoplan_campaign.backend.domain.recipients import Recipient, RecipientType +from govoplan_campaign.backend.services.attachment_matching import match_files +from govoplan_campaign.backend.services.zip_service import create_encrypted_zip + + +def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str: + return ", ".join(r.formatted() for r in recipients if r.type == recipient_type) + + +def _recipient_values(recipients: list[Recipient]) -> dict[str, str]: + def rows(recipient_type: RecipientType) -> list[Recipient]: + return [r for r in recipients if r.type == recipient_type] + + def joined(recipient_type: RecipientType, mode: str) -> str: + selected = rows(recipient_type) + if mode == "address": + return ", ".join(r.address for r in selected) + if mode == "name": + return ", ".join(r.name or r.address for r in selected) + return ", ".join(r.formatted() for r in selected) + + return { + "mm_recipients": joined(RecipientType.TO, "formatted"), + "mm_recipients_address": joined(RecipientType.TO, "address"), + "mm_recipients_name": joined(RecipientType.TO, "name"), + "mm_cc": joined(RecipientType.CC, "formatted"), + "mm_cc_address": joined(RecipientType.CC, "address"), + "mm_cc_name": joined(RecipientType.CC, "name"), + "mm_bcc": joined(RecipientType.BCC, "formatted"), + "mm_bcc_address": joined(RecipientType.BCC, "address"), + "mm_bcc_name": joined(RecipientType.BCC, "name"), + } + + +def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]: + paths: list[Path] = [] + if entry.combine_attachments: + for config in campaign.global_attachment_configs: + paths.extend(match_files(campaign.base_attachment_path, config)) + if campaign.individual_attachments: + for config in entry.attachment_configs: + paths.extend(match_files(campaign.base_attachment_path, config)) + return paths + + +def get_not_sent_files(campaign: MailCampaign) -> set[Path]: + """Return files matching campaign attachment configs that are not referenced by any built entry.""" + all_files: set[Path] = set() + used_files: set[Path] = set() + for config in campaign.global_attachment_configs: + all_files.update(match_files(campaign.base_attachment_path, config)) + for entry in campaign.mail_entries: + files = set(_message_attachment_paths(campaign, entry)) + used_files.update(files) + all_files.update(files) + return all_files - used_files + + +def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None: + recipients = campaign.all_recipients_for(entry) + attachments = _message_attachment_paths(campaign, entry) + if not attachments and not campaign.send_without_attachments: + return None + + values = {} + values.update(_recipient_values(recipients)) + values.update(campaign.field_contents.as_value_map("global")) + values.update(entry.field_contents.as_value_map("local")) + + message = EmailMessage() + sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from + if sender: + message["From"] = sender.formatted() + to_header = _recipient_header(recipients, RecipientType.TO) + cc_header = _recipient_header(recipients, RecipientType.CC) + if to_header: + message["To"] = to_header + if cc_header: + message["Cc"] = cc_header + message["Subject"] = campaign.subject_template.apply_values(values) + message.set_content(campaign.mail_template.apply_values(values)) + + attachment_paths = attachments + + if attachments and zip_attachments: + number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments" + password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else "" + zip_path = campaign.base_attachment_path / f"{number}.zip" + attachment_paths = [create_encrypted_zip(zip_path, attachments, password)] + + for attachment_path in attachment_paths: + data = attachment_path.read_bytes() + message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name) + return message + + +def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue: + queue = MailQueue() + for entry in campaign.mail_entries: + if not entry.is_active: + continue + message = build_message(campaign, entry, zip_attachments=zip_attachments) + if message is not None: + queue.add_mail(message) + return queue + + +def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue: + retry_queue = MailQueue() + if settings.transport_security == TransportSecurity.TLS: + smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60) + else: + smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60) + try: + if settings.transport_security == TransportSecurity.STARTTLS: + smtp.starttls() + if settings.username: + smtp.login(settings.username, settings.password) + for message in queue: + try: + smtp.send_message(message) + except Exception: + retry_queue.add_mail(message) + finally: + smtp.quit() + return retry_queue diff --git a/src/govoplan_campaign/backend/services/zip_service.py b/src/govoplan_campaign/backend/services/zip_service.py new file mode 100644 index 0000000..d732a8a --- /dev/null +++ b/src/govoplan_campaign/backend/services/zip_service.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import zipfile +from pathlib import Path +from typing import Iterable + +try: + import pyzipper +except ImportError: # pragma: no cover + pyzipper = None + +ArchiveMember = tuple[Path, str] + + +def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]: + members: list[ArchiveMember] = [] + used_names: set[str] = set() + for item in files: + path, requested_name = item if isinstance(item, tuple) else (item, item.name) + requested = Path(requested_name).name or path.name + stem = Path(requested).stem + suffix = Path(requested).suffix + candidate = requested + counter = 2 + while candidate.casefold() in used_names: + candidate = f"{stem} ({counter}){suffix}" + counter += 1 + used_names.add(candidate.casefold()) + members.append((path, candidate)) + return members + + +def create_zip_archive( + output_path: Path, + files: Iterable[Path | ArchiveMember], + password: str = "", +) -> Path: + """Create a ZIP archive, using AES encryption when a password is supplied.""" + + output_path.parent.mkdir(parents=True, exist_ok=True) + members = _normalized_members(files) + if password: + if pyzipper is None: + raise RuntimeError("pyzipper is required for writing password-protected ZIP files") + with pyzipper.AESZipFile( + output_path, + "w", + compression=pyzipper.ZIP_DEFLATED, + encryption=pyzipper.WZ_AES, + ) as zip_file: + zip_file.setpassword(password.encode("utf-8")) + for file_path, archive_name in members: + zip_file.write(file_path, arcname=archive_name) + return output_path + + with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file: + for file_path, archive_name in members: + zip_file.write(file_path, arcname=archive_name) + return output_path + + +def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path: + """Backward-compatible wrapper for the original per-rule ZIP helper.""" + + return create_zip_archive(output_path, files, password) diff --git a/src/govoplan_campaign/backend/time.py b/src/govoplan_campaign/backend/time.py new file mode 100644 index 0000000..163654b --- /dev/null +++ b/src/govoplan_campaign/backend/time.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from datetime import datetime, timezone + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +def ensure_aware_utc(value: datetime | None) -> datetime | None: + """Return a timezone-aware UTC datetime. + + SQLite and some DB drivers may return naive datetimes even when SQLAlchemy + columns are declared as DateTime(timezone=True). Treat naive values as UTC + so comparisons with aware `datetime.now(timezone.utc)` do not crash. + """ + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) diff --git a/src/govoplan_campaign/py.typed b/src/govoplan_campaign/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..7c4bdc9 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,27 @@ +{ + "name": "@govoplan/campaign-webui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./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.0", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1" + } +} diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts new file mode 100644 index 0000000..2dccc16 --- /dev/null +++ b/webui/src/api/admin.ts @@ -0,0 +1,519 @@ +import type { ApiSettings } from "../types"; +import { apiFetch } from "./client"; + +export type PermissionItem = { + scope: string; + label: string; + description: string; + category: string; + level: "tenant" | "system"; + system_template_id?: string | null; + system_required?: boolean; +}; + +export type AdminOverview = { + active_tenant_id: string; + active_tenant_name: string; + tenant_count?: number | null; + system_account_count?: number | null; + system_group_template_count?: number | null; + system_role_template_count?: number | null; + user_count: number; + active_user_count: number; + group_count: number; + role_count: number; + active_api_key_count: number; + capabilities: string[]; +}; + +export type TenantAdminItem = { + id: string; + slug: string; + name: string; + description?: string | null; + default_locale: string; + settings: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; + effective_governance: Record; + is_active: boolean; + counts: Record; + created_at: string; + updated_at: string; +}; + +export type TenantOwnerCandidate = { + account_id: string; + email: string; + display_name?: string | null; +}; + +export type RoleSummary = { + id: string; + slug: string; + name: string; + description?: string | null; + permissions: string[]; + effective_permission_count: number; + is_builtin: boolean; + is_assignable: boolean; + user_assignments: number; + group_assignments: number; + level: "tenant" | "system"; + system_template_id?: string | null; + system_required?: boolean; +}; + +export type GroupSummary = { + id: string; + slug: string; + name: string; + description?: string | null; + is_active: boolean; + member_count: number; + member_ids: string[]; + roles: RoleSummary[]; + created_at: string; + updated_at: string; + system_template_id?: string | null; + system_required?: boolean; +}; + +export type UserAdminItem = { + id: string; + account_id: string; + tenant_id: string; + email: string; + display_name?: string | null; + is_active: boolean; + account_is_active: boolean; + password_reset_required: boolean; + last_login_at?: string | null; + groups: GroupSummary[]; + roles: RoleSummary[]; + effective_scopes: string[]; + is_owner: boolean; + is_last_active_owner: boolean; + created_at: string; + updated_at: string; +}; + +export type SystemAccountItem = { + account_id: string; + email: string; + display_name?: string | null; + is_active: boolean; + memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>; + roles: RoleSummary[]; + last_login_at?: string | null; +}; + + +export type SystemMembershipDraft = { + tenant_id: string; + is_active: boolean; + role_ids: string[]; + group_ids: string[]; + is_owner?: boolean; + is_last_active_owner?: boolean; +}; + +export type PrivacyRetentionPolicyFieldKey = + | "store_raw_campaign_json" + | "raw_campaign_json_retention_days" + | "generated_eml_retention_days" + | "stored_report_detail_retention_days" + | "mock_mailbox_retention_days" + | "audit_detail_retention_days" + | "audit_detail_level"; + +export type PrivacyRetentionLimitPermissions = Record; +export type PrivacyRetentionLimitPermissionPatch = Partial; + +export type PrivacyRetentionPolicy = { + store_raw_campaign_json: boolean; + raw_campaign_json_retention_days?: number | null; + generated_eml_retention_days?: number | null; + stored_report_detail_retention_days?: number | null; + mock_mailbox_retention_days?: number | null; + audit_detail_retention_days?: number | null; + audit_detail_level: "full" | "redacted" | "minimal"; + allow_lower_level_limits: PrivacyRetentionLimitPermissions; +}; + +export type PrivacyRetentionPolicyPatch = Partial> & { + allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch; +}; +export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign"; + +export type PrivacyRetentionPolicyScopeResponse = { + scope_type: PrivacyRetentionPolicyScope; + scope_id?: string | null; + policy: PrivacyRetentionPolicyPatch; + effective_policy: PrivacyRetentionPolicy; + parent_policy?: PrivacyRetentionPolicy | null; +}; + +export type SystemSettingsItem = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy: PrivacyRetentionPolicy; + settings: Record; +}; + +export type RetentionRunResponse = { + result: { + dry_run: boolean; + policy: PrivacyRetentionPolicy; + cutoffs: Record; + effective_policy_scope?: string; + counts: Record>; + }; +}; + +export type GovernanceAssignment = { + tenant_id: string; + mode: "available" | "required"; +}; + +export type GovernanceTemplateItem = { + id: string; + kind: "group" | "role"; + slug: string; + name: string; + description?: string | null; + permissions: string[]; + effective_permission_count: number; + is_active: boolean; + assignments: GovernanceAssignment[]; + created_at: string; + updated_at: string; +}; + +export type ApiKeyAdminItem = { + id: string; + user_id: string; + user_email: string; + name: string; + prefix: string; + scopes: string[]; + expires_at?: string | null; + last_used_at?: string | null; + revoked_at?: string | null; + created_at: string; +}; + +export type AuditAdminItem = { + id: string; + scope: "tenant" | "system"; + tenant_id?: string | null; + actor_email?: string | null; + action: string; + object_type?: string | null; + object_id?: string | null; + details: Record; + created_at: string; +}; + + + +export function fetchAdminOverview(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/overview"); +} + +export async function fetchPermissionCatalog(settings: ApiSettings): Promise { + const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions"); + return response.permissions; +} + +export async function fetchTenants(settings: ApiSettings): Promise { + const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); + return response.tenants; +} + +export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise { + const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates"); + return response.accounts; +} + +export function createTenant(settings: ApiSettings, payload: { + slug: string; + name: string; + owner_account_id?: string | null; + description?: string | null; + default_locale?: string; + settings?: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; +}): Promise { + return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{ + name: string; + description: string | null; + default_locale: string; + settings: Record; + allow_custom_groups?: boolean | null; + allow_custom_roles?: boolean | null; + allow_api_keys?: boolean | null; + effective_governance: Record; + is_active: boolean; +}>): Promise { + return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export async function fetchUsers(settings: ApiSettings): Promise { + const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users"); + return response.users; +} + +export function createUser(settings: ApiSettings, payload: { + email: string; + display_name?: string | null; + password?: string | null; + password_reset_required?: boolean; + is_active?: boolean; + group_ids?: string[]; + role_ids?: string[]; +}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> { + return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{ + display_name: string | null; + is_active: boolean; + group_ids: string[]; + role_ids: string[]; +}>): Promise { + return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export async function fetchGroups(settings: ApiSettings): Promise { + const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups"); + return response.groups; +} + +export function createGroup(settings: ApiSettings, payload: { + slug: string; + name: string; + description?: string | null; + is_active?: boolean; + member_ids?: string[]; + role_ids?: string[]; +}): Promise { + return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{ + name: string; + description: string | null; + is_active: boolean; + member_ids: string[]; + role_ids: string[]; +}>): Promise { + return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export async function fetchRoles(settings: ApiSettings): Promise { + const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles"); + return response.roles; +} + +export function createRole(settings: ApiSettings, payload: { + slug: string; + name: string; + description?: string | null; + permissions: string[]; +}): Promise { + return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateRole(settings: ApiSettings, roleId: string, payload: { + name: string; + description?: string | null; + permissions: string[]; + is_assignable: boolean; +}): Promise { + return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteRole(settings: ApiSettings, roleId: string): Promise { + return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" }); +} + +export async function fetchSystemRoles(settings: ApiSettings): Promise { + const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles"); + return response.roles; +} + +export function createSystemRole(settings: ApiSettings, payload: { + slug: string; + name: string; + description?: string | null; + permissions: string[]; +}): Promise { + return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateSystemRole(settings: ApiSettings, roleId: string, payload: { + name: string; + description?: string | null; + permissions: string[]; + is_assignable: boolean; +}): Promise { + return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" }); +} + +export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> { + return apiFetch(settings, "/api/v1/admin/system/accounts"); +} + +export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: { + display_name?: string | null; + is_active?: boolean; + role_ids?: string[]; +}): Promise { + return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, { + method: "PATCH", + body: JSON.stringify(payload) + }); +} + +export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise { + return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, { + method: "PUT", + body: JSON.stringify({ role_ids: roleIds }) + }); +} + +export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise { + const params = new URLSearchParams(); + if (includeRevoked) params.set("include_revoked", "true"); + const suffix = params.toString() ? `?${params.toString()}` : ""; + const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`); + return response.api_keys; +} + +export function createApiKey(settings: ApiSettings, payload: { + name: string; + user_id?: string | null; + scopes: string[]; + expires_at?: string | null; +}): Promise { + return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) }); +} + +export function revokeApiKey(settings: ApiSettings, keyId: string): Promise { + return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" }); +} + +export type AuditQueryOptions = { + tenantId?: string | null; + allTenants?: boolean; + scope?: "tenant" | "system"; + limit?: number; + offset?: number; + page?: number; + pageSize?: number; + sortBy?: "time" | "actor" | "action" | "object" | "tenant"; + sortDirection?: "asc" | "desc"; + filters?: Partial>; +}; + +export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> { + const params = new URLSearchParams(); + if (options.tenantId) params.set("tenant_id", options.tenantId); + if (options.allTenants) params.set("all_tenants", "true"); + if (options.scope) params.set("scope", options.scope); + if (options.limit) params.set("limit", String(options.limit)); + if (options.offset) params.set("offset", String(options.offset)); + if (options.page) params.set("page", String(options.page)); + if (options.pageSize) params.set("page_size", String(options.pageSize)); + if (options.sortBy) params.set("sort_by", options.sortBy); + if (options.sortDirection) params.set("sort_direction", options.sortDirection); + for (const [column, value] of Object.entries(options.filters ?? {})) { + if (value?.trim()) params.set(`filter_${column}`, value); + } + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/audit${suffix}`); +} + + +export function createSystemAccount(settings: ApiSettings, payload: { + email: string; + display_name?: string | null; + password?: string | null; + password_reset_required?: boolean; + is_active?: boolean; + role_ids?: string[]; + memberships?: SystemMembershipDraft[]; +}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> { + return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise { + return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, { + method: "PUT", body: JSON.stringify({ memberships }) + }); +} + +export function fetchSystemSettings(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings"); +} + +export type SystemSettingsUpdatePayload = { + default_locale: string; + allow_tenant_custom_groups: boolean; + allow_tenant_custom_roles: boolean; + allow_tenant_api_keys: boolean; + privacy_retention_policy?: PrivacyRetentionPolicy | null; +}; + +export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise { + return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise { + const params = new URLSearchParams(); + if (scopeId) params.set("scope_id", scopeId); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`); +} + +export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null): Promise { + const params = new URLSearchParams(); + if (scopeId) params.set("scope_id", scopeId); + const suffix = params.toString() ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy }) }); +} + +export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise { + return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) }); +} + +export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise { + const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : ""; + const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`); + return response.templates; +} + +export function createGovernanceTemplate(settings: ApiSettings, payload: Omit): Promise { + return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) }); +} + +export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit): Promise { + return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) }); +} + +export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string): Promise { + return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "DELETE" }); +} diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts new file mode 100644 index 0000000..6b7f3c7 --- /dev/null +++ b/webui/src/api/campaigns.ts @@ -0,0 +1,680 @@ +import type { ApiSettings, CampaignListItem } from "../types"; +import { apiDownload, apiFetch } from "./client"; + +export type CampaignListResponse = + | CampaignListItem[] + | { + campaigns?: CampaignListItem[]; + items?: CampaignListItem[]; + results?: CampaignListItem[]; + }; + + + +export type CampaignShare = { + id: string; + campaign_id: string; + target_type: "user" | "group"; + target_id: string; + permission: "read" | "write"; + revoked_at?: string | null; +}; + +export type CampaignShareTarget = { id: string; name: string; secondary?: string | null }; +export type CampaignShareTargets = { users: CampaignShareTarget[]; groups: CampaignShareTarget[] }; + +export type CampaignUpdatePayload = { + external_id?: string | null; + name?: string | null; + status?: string | null; + description?: string | null; +}; + +export type CampaignCreateMinimalPayload = { + external_id?: string; + name?: string; + description?: string | null; + current_flow?: string; + current_step?: string; +}; + +export type CampaignCreateResponse = { + campaign: CampaignListItem & { + current_version_id?: string | null; + }; + version: CampaignVersionListItem; +}; + +export type CampaignVersionListItem = { + id: string; + campaign_id: string; + version_number: number; + schema_version?: string; + source_filename?: string | null; + source_base_path?: string | null; + workflow_state?: string; + current_flow?: string; + current_step?: string | null; + is_complete?: boolean; + editor_state?: Record; + autosaved_at?: string | null; + published_at?: string | null; + locked_at?: string | null; + locked_by_user_id?: string | null; + user_lock_state?: "temporary" | "permanent" | null; + user_locked_at?: string | null; + user_locked_by_user_id?: string | null; + created_at?: string; + updated_at?: string; + validation_summary?: Record | null; + build_summary?: Record | null; + execution_snapshot_hash?: string | null; + execution_snapshot_at?: string | null; +}; + +export type CampaignVersionDetail = CampaignVersionListItem & { + raw_json: Record; + campaign_json?: Record; +}; + +export type CampaignVersionUpdatePayload = { + campaign_json?: Record | null; + current_flow?: string | null; + current_step?: string | null; + workflow_state?: string | null; + is_complete?: boolean | null; + editor_state?: Record | null; + source_filename?: string | null; + source_base_path?: string | null; +}; + +export type CampaignPartialValidationPayload = { + campaign_json?: Record | null; + section?: string | null; +}; + +export type CampaignPartialValidationResponse = { + ok: boolean; + section?: string | null; + error_count: number; + warning_count: number; + info_count: number; + issues: Record[]; +}; + +export type CampaignSummary = { + generated_at?: string; + selected_version_id?: string | null; + campaign?: CampaignListItem; + current_version?: { + id: string; + version_number?: number; + schema_version?: string; + source_filename?: string | null; + created_at?: string | null; + validation_summary?: Record | null; + build_summary?: Record | null; + } | null; + cards?: { + jobs_total?: number; + inactive?: number; + queueable?: number; + needs_attention?: number; + sent?: number; + smtp_accepted?: number; + failed?: number; + outcome_unknown?: number; + not_attempted?: number; + queued_or_active?: number; + cancelled?: number; + partially_completed?: boolean; + imap_appended?: number; + imap_failed?: number; + }; + status_counts?: Record>; + issues?: Record; + attachments?: Record; + attempts?: Record; + delivery?: Record; + recent_failures?: Record[]; +}; + +export type CampaignQueuePayload = { + version_id?: string | null; + include_warnings?: boolean; + enqueue_celery?: boolean; + dry_run?: boolean; +}; + +export type CampaignSendNowPayload = { + version_id?: string | null; + include_warnings?: boolean; + check_files?: boolean; + validate_before_send?: boolean; + build_before_send?: boolean; + dry_run?: boolean; + use_rate_limit?: boolean; + enqueue_imap_task?: boolean; +}; + + +export type CampaignAttachmentPreviewFile = { + id: string; + version_id?: string; + blob_id?: string; + display_path: string; + filename: string; + owner_type: string; + owner_id: string; + checksum_sha256?: string; + size_bytes?: number; + content_type?: string | null; +}; + +export type CampaignAttachmentPreviewRule = { + source: "global" | "entry"; + entry_index: number; + entry_id?: string | null; + index: number; + attachment_id?: string | null; + label?: string | null; + required: boolean; + pattern: string; + base_path_name?: string | null; + base_path?: string | null; + status: "ok" | "missing" | "ambiguous"; + behavior?: string | null; + zip_included?: boolean; + zip_mode?: "inherit" | "include" | "exclude"; + zip_archive_id?: string | null; + zip_filename?: string | null; + matches: CampaignAttachmentPreviewFile[]; + match_count: number; + issues: Record[]; +}; + +export type CampaignAttachmentPreviewResponse = { + campaign_id: string; + version_id: string; + shared_file_count: number; + rules: CampaignAttachmentPreviewRule[]; + unused_shared_files: CampaignAttachmentPreviewFile[]; +}; + +export type CampaignAttachmentPreviewPayload = { + include_unmatched?: boolean; + campaign_json?: Record; +}; + +export type CampaignMockSendPayload = { + version_id?: string | null; + send?: boolean; + include_warnings?: boolean; + include_needs_review?: boolean; + append_sent?: boolean; + clear_mailbox?: boolean; + check_files?: boolean; +}; + +export type CampaignReviewStatePayload = { + inspection_complete: boolean; + reviewed_message_keys: string[]; +}; + + +export type CampaignJobsQuery = { + versionId?: string; + page?: number; + pageSize?: number; + sendStatus?: string[]; + validationStatus?: string[]; + imapStatus?: string[]; + query?: string; +}; + +export type CampaignJobsResponse = { + jobs: Record[]; + page: number; + page_size: number; + total: number; + total_unfiltered: number; + pages: number; + counts: Record>; + filtered_counts: Record>; + review: { + inspection_complete?: boolean; + blocking_count?: number; + required_count?: number; + reviewed_required_count?: number; + bulk_acceptable_count?: number; + }; +}; + +export type CampaignJobDetailResponse = { + job: Record; + attempts: { + smtp?: Record[]; + imap?: Record[]; + }; +}; + +export type CampaignReportEmailPayload = { + to: string[]; + version_id?: string; + include_jobs?: boolean; + attach_jobs_csv?: boolean; + attach_report_json?: boolean; + dry_run?: boolean; +}; + +export async function listCampaigns(settings: ApiSettings): Promise { + const response = await apiFetch(settings, "/api/v1/campaigns"); + + if (Array.isArray(response)) { + return response; + } + + return response.campaigns ?? response.items ?? response.results ?? []; +} + +export async function getCampaign(settings: ApiSettings, campaignId: string): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}`); +} + +export async function updateCampaignMetadata( + settings: ApiSettings, + campaignId: string, + payload: CampaignUpdatePayload +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}`, { + method: "PUT", + body: JSON.stringify(payload) + }); +} + +export async function createNewCampaign( + settings: ApiSettings, + overrides: CampaignCreateMinimalPayload = {} +): Promise { + const now = new Date(); + const stamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, ""); + const payload = { + external_id: overrides.external_id ?? `new-campaign-${stamp}`, + name: overrides.name ?? "New Campaign", + description: overrides.description ?? "", + current_flow: overrides.current_flow ?? "create", + current_step: overrides.current_step ?? "basics" + }; + + return apiFetch(settings, "/api/v1/campaigns/new", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function getCampaignSchema(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/schemas/campaign"); +} + +export async function listCampaignVersions( + settings: ApiSettings, + campaignId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions`); +} + +export async function getCampaignVersion( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`); +} + +export async function unlockCampaignVersionValidation( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-validation`, { + method: "POST" + }); +} + +export async function lockCampaignVersionTemporarily( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-temporarily`, { + method: "POST" + }); +} + +export async function unlockCampaignVersionUserLock( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/unlock-user-lock`, { + method: "POST" + }); +} + +export async function lockCampaignVersionPermanently( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/lock-permanently`, { + method: "POST" + }); +} + +export async function updateCampaignVersion( + settings: ApiSettings, + campaignId: string, + versionId: string, + payload: CampaignVersionUpdatePayload +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, { + method: "PUT", + body: JSON.stringify(payload) + }); +} + +export async function forkCampaignVersion( + settings: ApiSettings, + campaignId: string, + versionId: string, + payload: CampaignVersionUpdatePayload = {} +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function autosaveCampaignVersion( + settings: ApiSettings, + campaignId: string, + versionId: string, + payload: CampaignVersionUpdatePayload +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function setCampaignVersionStep( + settings: ApiSettings, + campaignId: string, + versionId: string, + currentStep: string, + currentFlow?: string | null +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/set-step`, { + method: "POST", + body: JSON.stringify({ current_flow: currentFlow, current_step: currentStep }) + }); +} + +export async function validatePartial( + settings: ApiSettings, + campaignId: string, + versionId: string, + payload: CampaignPartialValidationPayload = {} +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/validate-partial`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function publishCampaignVersion( + settings: ApiSettings, + campaignId: string, + versionId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/publish`, { + method: "POST" + }); +} + +export async function validateVersion( + settings: ApiSettings, + versionId: string, + checkFiles = false +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, { + method: "POST", + body: JSON.stringify({ check_files: checkFiles }) + }); +} + +export async function buildVersion( + settings: ApiSettings, + versionId: string, + writeEml = true +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/versions/${versionId}/build`, { + method: "POST", + body: JSON.stringify({ write_eml: writeEml }) + }); +} + + +export function previewCampaignAttachments( + settings: ApiSettings, + campaignId: string, + versionId: string, + payload: CampaignAttachmentPreviewPayload = {} +): Promise { + return apiFetch( + settings, + `/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + +export async function getCampaignSummary( + settings: ApiSettings, + campaignId: string, + versionId?: string +): Promise { + const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`); +} + +export async function getCampaignJobs( + settings: ApiSettings, + campaignId: string, + options: CampaignJobsQuery = {} +): Promise { + const params = new URLSearchParams(); + if (options.versionId) params.set("version_id", options.versionId); + if (options.page) params.set("page", String(options.page)); + if (options.pageSize) params.set("page_size", String(options.pageSize)); + for (const value of options.sendStatus ?? []) params.append("send_status", value); + for (const value of options.validationStatus ?? []) params.append("validation_status", value); + for (const value of options.imapStatus ?? []) params.append("imap_status", value); + if (options.query?.trim()) params.set("q", options.query.trim()); + const suffix = params.size > 0 ? `?${params.toString()}` : ""; + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`); +} + +export async function getCampaignJobDetail( + settings: ApiSettings, + campaignId: string, + jobId: string +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}`); +} + +export async function getCampaignReport( + settings: ApiSettings, + campaignId: string, + versionId?: string +): Promise { + const params = new URLSearchParams({ include_jobs: "false" }); + if (versionId) params.set("version_id", versionId); + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`); +} + +export async function downloadCampaignJobsCsv( + settings: ApiSettings, + campaignId: string, + versionId?: string +): Promise { + const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : ""; + await apiDownload( + settings, + `/api/v1/campaigns/${campaignId}/report/jobs.csv${suffix}`, + `campaign-${campaignId}${versionId ? `-${versionId}` : ""}-jobs.csv` + ); +} + +export async function emailCampaignReport( + settings: ApiSettings, + campaignId: string, + payload: CampaignReportEmailPayload +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/report/email`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function retryCampaignJobs( + settings: ApiSettings, + campaignId: string, + payload: Record = {} +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/jobs/retry`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function sendUnattemptedCampaignJobs( + settings: ApiSettings, + campaignId: string, + payload: Record = {} +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/jobs/send-unattempted`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function resolveCampaignJobOutcome( + settings: ApiSettings, + campaignId: string, + jobId: string, + decision: "smtp_accepted" | "not_sent", + note?: string +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, { + method: "POST", + body: JSON.stringify({ decision, note: note || null }) + }); +} + +export async function updateCampaignReviewState( + settings: ApiSettings, + campaignId: string, + versionId: string, + payload: CampaignReviewStatePayload +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/review-state`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function queueCampaign( + settings: ApiSettings, + campaignId: string, + payload: CampaignQueuePayload = {} +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/queue`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function sendCampaignNow( + settings: ApiSettings, + campaignId: string, + payload: CampaignSendNowPayload = {} +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/send-now`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + + +export async function mockSendCampaign( + settings: ApiSettings, + campaignId: string, + payload: CampaignMockSendPayload = {} +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function pauseCampaign(settings: ApiSettings, campaignId: string): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/pause`, { method: "POST" }); +} + +export async function resumeCampaign(settings: ApiSettings, campaignId: string): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/resume`, { method: "POST" }); +} + +export async function cancelCampaign(settings: ApiSettings, campaignId: string): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/cancel`, { method: "POST" }); +} + +export async function appendSent( + settings: ApiSettings, + campaignId: string, + dryRun = false +): Promise> { + return apiFetch>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, { + method: "POST", + body: JSON.stringify({ dry_run: dryRun }) + }); +} + +export async function getCampaignShareTargets(settings: ApiSettings, campaignId: string): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/share-targets`); +} + +export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise { + const response = await apiFetch<{ shares: CampaignShare[] }>(settings, `/api/v1/campaigns/${campaignId}/shares`); + return response.shares; +} + +export async function updateCampaignOwner( + settings: ApiSettings, + campaignId: string, + payload: { owner_user_id?: string | null; owner_group_id?: string | null } +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/owner`, { method: "PUT", body: JSON.stringify(payload) }); +} + +export async function upsertCampaignShare( + settings: ApiSettings, + campaignId: string, + payload: { target_type: "user" | "group"; target_id: string; permission: "read" | "write" } +): Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/shares`, { method: "POST", body: JSON.stringify(payload) }); +} + +export async function revokeCampaignShare(settings: ApiSettings, campaignId: string, shareId: string): Promise { + await apiFetch(settings, `/api/v1/campaigns/${campaignId}/shares/${shareId}`, { method: "DELETE" }); +} diff --git a/webui/src/api/client.ts b/webui/src/api/client.ts new file mode 100644 index 0000000..c1673ff --- /dev/null +++ b/webui/src/api/client.ts @@ -0,0 +1 @@ +export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui"; diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts new file mode 100644 index 0000000..a724844 --- /dev/null +++ b/webui/src/api/files.ts @@ -0,0 +1 @@ +export * from "@govoplan/files-webui"; diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts new file mode 100644 index 0000000..1b00321 --- /dev/null +++ b/webui/src/api/mail.ts @@ -0,0 +1 @@ +export * from "@govoplan/mail-webui"; diff --git a/webui/src/components/Button.tsx b/webui/src/components/Button.tsx new file mode 100644 index 0000000..710440c --- /dev/null +++ b/webui/src/components/Button.tsx @@ -0,0 +1,2 @@ +import { Button } from "@govoplan/core-webui"; +export default Button; diff --git a/webui/src/components/Card.tsx b/webui/src/components/Card.tsx new file mode 100644 index 0000000..e1eeb17 --- /dev/null +++ b/webui/src/components/Card.tsx @@ -0,0 +1,2 @@ +import { Card } from "@govoplan/core-webui"; +export default Card; diff --git a/webui/src/components/ConfirmDialog.tsx b/webui/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..eeb657c --- /dev/null +++ b/webui/src/components/ConfirmDialog.tsx @@ -0,0 +1,2 @@ +import { ConfirmDialog } from "@govoplan/core-webui"; +export default ConfirmDialog; diff --git a/webui/src/components/Dialog.tsx b/webui/src/components/Dialog.tsx new file mode 100644 index 0000000..e0b9e8b --- /dev/null +++ b/webui/src/components/Dialog.tsx @@ -0,0 +1,2 @@ +import { Dialog } from "@govoplan/core-webui"; +export default Dialog; diff --git a/webui/src/components/DismissibleAlert.tsx b/webui/src/components/DismissibleAlert.tsx new file mode 100644 index 0000000..09ad137 --- /dev/null +++ b/webui/src/components/DismissibleAlert.tsx @@ -0,0 +1,2 @@ +import { DismissibleAlert } from "@govoplan/core-webui"; +export default DismissibleAlert; diff --git a/webui/src/components/FormField.tsx b/webui/src/components/FormField.tsx new file mode 100644 index 0000000..59b6792 --- /dev/null +++ b/webui/src/components/FormField.tsx @@ -0,0 +1,2 @@ +import { FormField } from "@govoplan/core-webui"; +export default FormField; diff --git a/webui/src/components/LoadingFrame.tsx b/webui/src/components/LoadingFrame.tsx new file mode 100644 index 0000000..c007f26 --- /dev/null +++ b/webui/src/components/LoadingFrame.tsx @@ -0,0 +1,2 @@ +import { LoadingFrame } from "@govoplan/core-webui"; +export default LoadingFrame; diff --git a/webui/src/components/LoadingIndicator.tsx b/webui/src/components/LoadingIndicator.tsx new file mode 100644 index 0000000..e2e63cc --- /dev/null +++ b/webui/src/components/LoadingIndicator.tsx @@ -0,0 +1,2 @@ +import { LoadingIndicator } from "@govoplan/core-webui"; +export default LoadingIndicator; diff --git a/webui/src/components/MetricCard.tsx b/webui/src/components/MetricCard.tsx new file mode 100644 index 0000000..5af7d1f --- /dev/null +++ b/webui/src/components/MetricCard.tsx @@ -0,0 +1,2 @@ +import { MetricCard } from "@govoplan/core-webui"; +export default MetricCard; diff --git a/webui/src/components/PageTitle.tsx b/webui/src/components/PageTitle.tsx new file mode 100644 index 0000000..2a58990 --- /dev/null +++ b/webui/src/components/PageTitle.tsx @@ -0,0 +1,2 @@ +import { PageTitle } from "@govoplan/core-webui"; +export default PageTitle; diff --git a/webui/src/components/StatusBadge.tsx b/webui/src/components/StatusBadge.tsx new file mode 100644 index 0000000..13347b8 --- /dev/null +++ b/webui/src/components/StatusBadge.tsx @@ -0,0 +1,2 @@ +import { StatusBadge } from "@govoplan/core-webui"; +export default StatusBadge; diff --git a/webui/src/components/Stepper.tsx b/webui/src/components/Stepper.tsx new file mode 100644 index 0000000..fc27f21 --- /dev/null +++ b/webui/src/components/Stepper.tsx @@ -0,0 +1,2 @@ +import { Stepper } from "@govoplan/core-webui"; +export default Stepper; diff --git a/webui/src/components/ToggleSwitch.tsx b/webui/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000..cae981b --- /dev/null +++ b/webui/src/components/ToggleSwitch.tsx @@ -0,0 +1,2 @@ +import { ToggleSwitch } from "@govoplan/core-webui"; +export default ToggleSwitch; diff --git a/webui/src/components/email/EmailAddressInput.tsx b/webui/src/components/email/EmailAddressInput.tsx new file mode 100644 index 0000000..40f5009 --- /dev/null +++ b/webui/src/components/email/EmailAddressInput.tsx @@ -0,0 +1,2 @@ +import { EmailAddressInput } from "@govoplan/core-webui"; +export default EmailAddressInput; diff --git a/webui/src/components/help/FieldLabel.tsx b/webui/src/components/help/FieldLabel.tsx new file mode 100644 index 0000000..b205494 --- /dev/null +++ b/webui/src/components/help/FieldLabel.tsx @@ -0,0 +1,2 @@ +import { FieldLabel } from "@govoplan/core-webui"; +export default FieldLabel; diff --git a/webui/src/components/help/InlineHelp.tsx b/webui/src/components/help/InlineHelp.tsx new file mode 100644 index 0000000..d2cb6bf --- /dev/null +++ b/webui/src/components/help/InlineHelp.tsx @@ -0,0 +1,2 @@ +import { InlineHelp } from "@govoplan/core-webui"; +export default InlineHelp; diff --git a/webui/src/components/table/DataGrid.tsx b/webui/src/components/table/DataGrid.tsx new file mode 100644 index 0000000..ec10c13 --- /dev/null +++ b/webui/src/components/table/DataGrid.tsx @@ -0,0 +1,4 @@ +import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui"; +export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui"; +export { DataGridEmptyAction, DataGridRowActions }; +export default DataGrid; diff --git a/webui/src/features/addressbook/AddressBookPage.tsx b/webui/src/features/addressbook/AddressBookPage.tsx new file mode 100644 index 0000000..c237ae7 --- /dev/null +++ b/webui/src/features/addressbook/AddressBookPage.tsx @@ -0,0 +1,97 @@ +import Button from "../../components/Button"; +import Card from "../../components/Card"; +import PageTitle from "../../components/PageTitle"; +import StatusBadge from "../../components/StatusBadge"; +import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; + +const personalContacts = [ + { name: "Ada Lovelace", email: "ada@example.local", source: "Personal", tags: "Used recently" }, + { name: "Grace Hopper", email: "grace@example.local", source: "Personal", tags: "Favorite" } +]; + +const groupContacts = [ + { name: "Project Office", email: "project-office@example.local", source: "Group", tags: "Shared" }, + { name: "Finance Team", email: "finance@example.local", source: "Group", tags: "Shared list" } +]; + +const tenantContacts = [ + { name: "Helpdesk", email: "helpdesk@example.local", source: "Tenant", tags: "Directory" }, + { name: "Data Protection", email: "privacy@example.local", source: "Tenant", tags: "Directory" } +]; + +function contactColumns(): DataGridColumn<{ name: string; email: string; source: string; tags: string }>[] { + return [ + { id: "name", header: "Name", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => {contact.name}, value: (contact) => contact.name }, + { id: "email", header: "Email", width: 240, sortable: true, filterable: true, value: (contact) => contact.email }, + { id: "scope", header: "Scope", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "Personal" }, { value: "Group", label: "Group" }, { value: "Tenant", label: "Tenant" }] }, value: (contact) => contact.source }, + { id: "tags", header: "Tags", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags }, + { id: "status", header: "Status", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "Mock" }], display: "pill" }, render: () => , value: () => "mock" } + ]; +} + +export default function AddressBookPage() { + const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts]; + + return ( +
+
+
+ Address Book +

Mock workspace for personal, group and tenant address books. These contacts can later feed recipient autocomplete and reusable address selections.

+
+
+ + +
+
+ +
+ {personalContacts.length}

Private contacts and remembered addresses.

+ {groupContacts.length}

Shared group address books and lists.

+ {tenantContacts.length}

Tenant directory and approved shared contacts.

+ Mock

CardDAV/LDAP/import connectors can be added later.

+
+ +
+ +
+ + + +
+
+ +
+ Choose addresses from personal, group or tenant source + Remember addresses used in campaigns after opt-in + Share selected contacts with a group + Use contacts in To, CC, BCC, sender and Reply-To fields +
+
+
+ + Manage sources}> + contact.source + "-" + contact.email} + emptyText="No contacts found." + className="compact-table-wrap module-table" + /> + +
+ ); +} + +function AddressBookScope({ title, description, status }: { title: string; description: string; status: string }) { + return ( +
+
+ {title} +

{description}

+
+ +
+ ); +} diff --git a/webui/src/features/campaigns/AttachmentsDataPage.tsx b/webui/src/features/campaigns/AttachmentsDataPage.tsx new file mode 100644 index 0000000..4bae1c1 --- /dev/null +++ b/webui/src/features/campaigns/AttachmentsDataPage.tsx @@ -0,0 +1,623 @@ +import { useEffect, useMemo, useState } from "react"; +import { Pencil } from "lucide-react"; +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 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 DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; +import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; +import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; +import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; +import { updateNested } from "./utils/draftEditor"; +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 { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions"; +import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; + +type PathChooserState = { index: number }; +type IndividualDisableState = { index: number; usageCount: number }; + +export default function AttachmentsDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { + const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); + const [pathChooser, setPathChooser] = useState(null); + const [fileSpaces, setFileSpaces] = useState([]); + const [individualDisable, setIndividualDisable] = useState(null); + const [zipNameEditorIndex, setZipNameEditorIndex] = useState(null); + const version = data.currentVersion; + const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); + const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "files", + unsavedTitle: "Unsaved attachment settings", + unsavedMessage: "Attachment settings have unsaved changes. Save them before leaving, or discard them and continue." + }); + const attachments = asRecord(displayDraft.attachments); + const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]); + const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]); + const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]); + const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]); + const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]); + const zipArchiveNameValidation = useMemo( + () => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION, + [zipConfig.archives, zipConfig.enabled] + ); + const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message; + const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]); + const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]); + + useEffect(() => { + 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]); + + function patchBasePaths(paths: AttachmentBasePath[]) { + if (locked) return; + const normalized = ensureAttachmentBasePaths(paths); + setDraft((current) => { + const withPaths = updateNested(current ?? {}, ["attachments", "base_paths"], normalized); + return updateNested(withPaths, ["attachments", "base_path"], normalized[0]?.path || "."); + }); + markDirty(); + } + + function patchBasePath(index: number, patch: Partial) { + patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath)); + } + + function addBasePath(afterIndex = basePaths.length - 1) { + patchBasePaths(insertAfter(basePaths, afterIndex, createAttachmentBasePath("New attachment source", "."))); + } + + function removeBasePath(index: number) { + patchBasePaths(basePaths.filter((_, currentIndex) => currentIndex !== index)); + } + + function moveBasePath(index: number, targetIndex: number) { + if (locked || index === targetIndex) return; + patchBasePaths(moveArrayItem(basePaths, index, targetIndex)); + } + + function setIndividualEligibility(index: number, checked: boolean) { + if (locked) return; + if (checked) { + patchBasePath(index, { allow_individual: true }); + return; + } + const basePath = basePaths[index]; + if (!basePath) return; + const usageCount = countIndividualAttachmentRulesForBasePath(displayDraft.entries, basePath); + if (usageCount > 0) { + setIndividualDisable({ index, usageCount }); + return; + } + patchBasePath(index, { allow_individual: false }); + } + + function confirmIndividualDisable() { + if (!individualDisable) return; + const basePath = basePaths[individualDisable.index]; + if (!basePath) { + setIndividualDisable(null); + return; + } + const nextPaths = basePaths.map((item, index) => index === individualDisable.index ? { ...item, allow_individual: false } : item); + setDraft((current) => { + const source = current ?? {}; + const withPaths = updateNested(source, ["attachments", "base_paths"], nextPaths); + const withPrimaryPath = updateNested(withPaths, ["attachments", "base_path"], nextPaths[0]?.path || "."); + return updateNested(withPrimaryPath, ["entries"], removeIndividualAttachmentRulesForBasePath(asRecord(source).entries, basePath)); + }); + markDirty(); + setIndividualDisable(null); + } + + + + function patchZipCollection(next: AttachmentZipCollection) { + if (locked) return; + patch(["attachments", "zip"], next); + } + + function setZipEnabled(enabled: boolean) { + const archives = enabled && zipConfig.archives.length === 0 + ? [createAttachmentZipArchive("{{local:id}}-attachments.zip", true)] + : zipConfig.archives; + patchZipCollection({ enabled, archives }); + } + + function patchZipArchive(index: number, archivePatch: Partial) { + const archives = zipConfig.archives.map((archive, currentIndex) => { + if (currentIndex !== index) return archive; + const next = { ...archive, ...archivePatch }; + if (archivePatch.password_field !== undefined || archivePatch.password_scope !== undefined) { + next.password_mode = "field"; + delete next.password; + delete next.password_template; + } + return next; + }); + patchZipCollection({ ...zipConfig, archives }); + } + + function setStandardZipArchive(index: number) { + patchZipCollection({ + ...zipConfig, + archives: zipConfig.archives.map((archive, currentIndex) => ({ ...archive, standard: currentIndex === index })) + }); + } + + function addZipArchive(afterIndex = zipConfig.archives.length - 1) { + const archive = createAttachmentZipArchive(`attachments-${zipConfig.archives.length + 1}.zip`, zipConfig.archives.length === 0); + patchZipCollection({ ...zipConfig, archives: insertAfter(zipConfig.archives, afterIndex, archive) }); + } + + function removeZipArchive(index: number) { + const removed = zipConfig.archives[index]; + if (!removed) return; + const removedWasStandard = removed.standard; + const archives = zipConfig.archives.filter((_, currentIndex) => currentIndex !== index); + if (removedWasStandard && archives.length > 0) archives[0] = { ...archives[0], standard: true }; + + const resetRule = (value: unknown) => { + const rule = asRecord(value); + const ruleZip = asRecord(rule.zip); + return String(ruleZip.archive_id ?? "") === removed.id + ? { ...rule, zip: { ...ruleZip, archive_id: "inherit" } } + : rule; + }; + setDraft((current) => { + const source = asRecord(current ?? {}); + const currentAttachments = asRecord(source.attachments); + const currentEntries = asRecord(source.entries); + return { + ...source, + attachments: { + ...currentAttachments, + zip: { ...zipConfig, archives }, + global: asArray(currentAttachments.global).map(resetRule) + }, + entries: { + ...currentEntries, + inline: asArray(currentEntries.inline).map((value) => { + const entry = asRecord(value); + return { ...entry, attachments: asArray(entry.attachments).map(resetRule) }; + }) + } + }; + }); + markDirty(); + } + + function moveZipArchive(index: number, targetIndex: number) { + if (locked || index === targetIndex) return; + patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) }); + } + + + return ( +
+
+
+ Attachments + +
+
+ + + +
+
+ + {error && {error}} + {localError && {localError}} + {locked && } + + + <> + + basePath.id} + emptyText="No attachment sources configured." + emptyAction={ addBasePath(-1)} disabled={locked} label="Add first attachment source" />} + className="attachment-sources-table-wrap attachment-sources-table" + /> + + + +
+ +
+ archive.id} + emptyText={zipConfig.enabled ? "No ZIP attachments configured." : "ZIP attachments are disabled."} + emptyAction={zipConfig.enabled ? addZipArchive(-1)} disabled={locked} label="Add first ZIP attachment" /> : undefined} + className="attachment-zip-table-wrap" + /> + {zipConfig.enabled && passwordFields.length === 0 && zipConfig.archives.some((archive) => archive.password_enabled) && ( + No password field exists. Add one under Fields with field type Password. + )} + {zipArchiveNameValidation.message && ( + {zipArchiveNameValidation.message} + )} +

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

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

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.

+
+ +
+ +
+ +
+ + field.namespace === "local").map(({ name, label }) => ({ name, label }))} + globalFields={filenameFieldOptions.filter((field) => field.namespace === "global").map(({ name, label }) => ({ name, label }))} + placeholder="attachments.zip" + description="Use a fixed filename or combine text with recipient and campaign fields. The .zip suffix is added automatically when omitted." + saveLabel="Save filename" + validate={(value) => zipNameEditorIndex === null ? "" : describeZipArchiveNameValueProblem(zipConfig.archives, zipNameEditorIndex, value)} + onCancel={() => setZipNameEditorIndex(null)} + onSave={(name) => { + if (zipNameEditorIndex === null) return; + patchZipArchive(zipNameEditorIndex, { name }); + setZipNameEditorIndex(null); + }} + onAddField={(name) => { + if (!draft || locked || !name) return; + const existingFields = asArray(draft.fields).map(asRecord); + if (existingFields.some((field) => String(field.name || field.id || "").trim() === name)) return; + patch(["fields"], [ + ...existingFields, + { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true } + ]); + }} + canAddField={(name) => !getDraftFields(displayDraft).some((field) => field.name === name)} + /> + + {pathChooser && ( + setPathChooser(null)} + onSelectFolder={(selection) => { + const current = basePaths[pathChooser.index]; + const folderLabel = selection.folderPath ? selection.folderPath.split("/").filter(Boolean).slice(-1)[0] : selection.space.label; + patchBasePath(pathChooser.index, { + source: selection.source, + path: selection.folderPath || ".", + name: !current?.name || current.name === "New attachment source" || current.name === "Campaign files" + ? `${selection.space.label}${selection.folderPath ? ` / ${folderLabel}` : ""}` + : current.name + }); + setPathChooser(null); + }} + /> + )} + + + setIndividualDisable(null)} + /> +
+ ); +} + +type ZipArchiveColumnContext = { + disabled: boolean; + archives: AttachmentZipArchive[]; + invalidNameIndexes: ReadonlySet; + onEditName: (index: number) => void; + passwordFields: ReturnType; + patchArchive: (index: number, patch: Partial) => void; + setStandard: (index: number) => void; + addArchive: (afterIndex?: number) => void; + moveArchive: (index: number, targetIndex: number) => void; + removeArchive: (index: number) => void; +}; + +function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn[] { + return [ + { + id: "name", header: "Archive name", width: "minmax(360px, 1fr)", resizable: true, sortable: true, filterable: true, sticky: "start", + render: (archive, index) => ( + + ), + value: (archive) => archive.name + }, + { + id: "standard", header: "Standard", width: 150, sortable: true, filterable: true, + columnType: "from-list", list: { options: [{ value: "standard", label: "Standard" }, { value: "optional", label: "Optional" }] }, + render: (archive, index) => checked && setStandard(index)} />, + value: (archive) => archive.standard ? "standard" : "optional" + }, + { + id: "password_enabled", header: "Password", width: 165, sortable: true, filterable: true, + columnType: "from-list", list: { options: [{ value: "protected", label: "Protected" }, { value: "none", label: "No password" }] }, + render: (archive, index) => patchArchive(index, { password_enabled: checked })} />, + value: (archive) => archive.password_enabled ? "protected" : "none" + }, + { + id: "password_field", header: "Password field", width: 230, sortable: true, filterable: true, + columnType: "from-list", list: { options: [{ value: "", label: "No field" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] }, + render: (archive, index) => ( + + ), + value: (archive) => archive.password_field + }, + { + id: "password_scope", header: "Value scope", width: 190, sortable: true, filterable: true, + columnType: "from-list", list: { options: [{ value: "local", label: "Recipient / local" }, { value: "global", label: "Campaign / global" }] }, + render: (archive, index) => ( + + ), + value: (archive) => archive.password_scope + }, + { + id: "actions", header: "Actions", width: 180, sticky: "end", + render: (_archive, index) => addArchive(index)} + onRemove={() => removeArchive(index)} + onMoveUp={index > 0 ? () => moveArchive(index, index - 1) : undefined} + onMoveDown={index < archives.length - 1 ? () => moveArchive(index, index + 1) : undefined} + addLabel="Add ZIP attachment below" + removeLabel="Remove ZIP attachment" + moveUpLabel="Move ZIP attachment up" + moveDownLabel="Move ZIP attachment down" + /> + } + ]; +} + +type ZipFilenameNamespace = "local" | "global"; + +type ZipFilenameFieldOption = { + namespace: ZipFilenameNamespace; + name: string; + label: string; +}; + +type ZipArchiveNameValidation = { + invalidIndexes: ReadonlySet; + message: string; +}; + +const EMPTY_ZIP_ARCHIVE_NAME_VALIDATION: ZipArchiveNameValidation = { + invalidIndexes: new Set(), + message: "" +}; + +function buildZipFilenameFieldOptions(draft: Record): ZipFilenameFieldOption[] { + const fieldDefinitions = new Map(getDraftFields(draft).map((field) => [field.name, field])); + const filenameFields = [...fieldDefinitions.values()].filter((field) => field.type !== "password"); + const labels = new Map(filenameFields.map((field) => [field.name, field.label || field.name])); + const localNames = uniqueStrings(["id", "name", "email", ...recipientAddressTemplateFieldOptions().map((field) => field.name), ...filenameFields.map((field) => field.name)]); + const globalNames = uniqueStrings([...filenameFields.map((field) => field.name), ...Object.keys(asRecord(draft.global_values))]) + .filter((name) => fieldDefinitions.get(name)?.type !== "password"); + return [ + ...localNames.map((name) => ({ namespace: "local" as const, name, label: labels.get(name) || humanizeFieldName(name) })), + ...globalNames.map((name) => ({ namespace: "global" as const, name, label: labels.get(name) || humanizeFieldName(name) })) + ]; +} + +function describeZipArchiveNameValueProblem(archives: AttachmentZipArchive[], index: number, value: string): string { + const normalized = normalizeZipArchiveName(value); + if (!normalized) return "Archive filename must not be empty."; + const duplicate = archives.some((archive, currentIndex) => currentIndex !== index && normalizeZipArchiveName(archive.name) === normalized); + return duplicate ? "Another ZIP attachment already uses this effective filename." : ""; +} + +function validateZipArchiveNames(archives: AttachmentZipArchive[]): ZipArchiveNameValidation { + const invalidIndexes = new Set(); + const byName = new Map(); + archives.forEach((archive, index) => { + const normalized = normalizeZipArchiveName(archive.name); + if (!normalized) { + invalidIndexes.add(index); + return; + } + const indexes = byName.get(normalized) ?? []; + indexes.push(index); + byName.set(normalized, indexes); + }); + for (const indexes of byName.values()) { + if (indexes.length < 2) continue; + indexes.forEach((index) => invalidIndexes.add(index)); + } + if (invalidIndexes.size === 0) return EMPTY_ZIP_ARCHIVE_NAME_VALIDATION; + const hasEmpty = archives.some((archive, index) => invalidIndexes.has(index) && !archive.name.trim()); + const duplicateNames = [...byName.entries()] + .filter(([, indexes]) => indexes.length > 1) + .map(([, indexes]) => archives[indexes[0]]?.name.trim()) + .filter(Boolean); + const parts: string[] = []; + if (hasEmpty) parts.push("Archive filenames must not be empty"); + if (duplicateNames.length > 0) parts.push(`Archive filenames must be unique: ${duplicateNames.join(", ")}`); + return { invalidIndexes, message: `${parts.join(". ")}. Saving is disabled until this is corrected.` }; +} + +function normalizeZipArchiveName(value: string): string { + const trimmed = value.trim().toLocaleLowerCase(); + if (!trimmed) return ""; + return trimmed.endsWith(".zip") ? trimmed : `${trimmed}.zip`; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +type AttachmentSourceColumnContext = { + locked: boolean; + basePaths: AttachmentBasePath[]; + fileSpaces: FileSpace[]; + patchBasePath: (index: number, patch: Partial) => void; + setIndividualEligibility: (index: number, checked: boolean) => void; + addBasePath: (afterIndex?: number) => void; + moveBasePath: (index: number, targetIndex: number) => void; + removeBasePath: (index: number) => void; + setPathChooser: (state: PathChooserState | null) => void; +}; + +function attachmentSourceColumns({ locked, basePaths, fileSpaces, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn[] { + return [ + { id: "name", header: "Name", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name }, + { + id: "path", + header: "Path", + width: "minmax(260px, 1fr)", + resizable: true, + sortable: true, + filterable: true, + render: (basePath, index) => ( +
+ !locked && setPathChooser({ index })} + onKeyDown={(event) => { + if (!locked && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + setPathChooser({ index }); + } + }} + /> + +
+ ), + value: (basePath) => formatAttachmentSourcePath(basePath, fileSpaces) + }, + { id: "individual", header: "Individual attachments", width: 260, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "individual", label: "Individual" }, { value: "global only", label: "Global only" }] }, render: (basePath, index) => setIndividualEligibility(index, checked)} />, value: (basePath) => basePath.allow_individual ? "individual" : "global only" }, + { id: "unsent_warning", header: "Unsent warning", width: 200, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "warn", label: "Warn" }, { value: "off", label: "Off" }] }, render: (basePath, index) => patchBasePath(index, { unsent_warning: checked })} />, value: (basePath) => basePath.unsent_warning ? "warn" : "off" }, + { + id: "actions", + header: "Actions", + width: 180, + sticky: "end", + render: (_basePath, index) => ( + addBasePath(index)} + onRemove={() => removeBasePath(index)} + onMoveUp={index > 0 ? () => moveBasePath(index, index - 1) : undefined} + onMoveDown={index < basePaths.length - 1 ? () => moveBasePath(index, index + 1) : undefined} + addLabel="Add attachment source below" + removeLabel="Remove attachment source" + moveUpLabel="Move attachment source up" + moveDownLabel="Move attachment source down" + /> + ) + } + ]; +} +function formatAttachmentSourcePath(basePath: AttachmentBasePath, spaces: FileSpace[]): string { + const parsedSource = parseManagedAttachmentSource(basePath.source); + const matchingSpace = parsedSource + ? spaces.find((space) => space.owner_type === parsedSource.ownerType && space.owner_id === parsedSource.ownerId) + : undefined; + const rootLabel = matchingSpace?.label + || (parsedSource?.ownerType === "user" ? "My files" : parsedSource?.ownerType === "group" ? "Group files" : ""); + const relativePath = basePath.path.trim().replace(/^\.\/?$/, "").replace(/^\/+|\/+$/g, ""); + if (rootLabel) return `${rootLabel}/${relativePath ? `${relativePath}/` : ""}`; + return `${relativePath || "."}/`; +} diff --git a/webui/src/features/campaigns/CampaignAuditPage.tsx b/webui/src/features/campaigns/CampaignAuditPage.tsx new file mode 100644 index 0000000..e18cf33 --- /dev/null +++ b/webui/src/features/campaigns/CampaignAuditPage.tsx @@ -0,0 +1,35 @@ +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 VersionLine from "./components/VersionLine"; +import LoadingFrame from "../../components/LoadingFrame"; +import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; + +export default function CampaignAuditPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { + const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId); + const version = data.currentVersion; + + return ( +
+
+
+ Audit log + +
+
+ +
+
+ + {error && {error}} + + + +

Campaign-specific audit API integration will be added in the audit section pass.

+
+
+
+ ); +} diff --git a/webui/src/features/campaigns/CampaignFieldsPage.tsx b/webui/src/features/campaigns/CampaignFieldsPage.tsx new file mode 100644 index 0000000..b0ba6f4 --- /dev/null +++ b/webui/src/features/campaigns/CampaignFieldsPage.tsx @@ -0,0 +1,308 @@ +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 LockedVersionNotice from "./components/LockedVersionNotice"; +import VersionLine from "./components/VersionLine"; +import ToggleSwitch from "../../components/ToggleSwitch"; +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 DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid"; +import { fieldTypeOptions, humanizeFieldName, normalizeFieldType, type CampaignFieldDefinition } from "./utils/fieldDefinitions"; +import { insertAfter, moveArrayItem } from "../../utils/arrayOrder"; + + +export default function CampaignFieldsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { + const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId); + const fieldValueKeys = useRef([]); + + const version = data.currentVersion; + const locked = isAuditLockedVersion(version, data.campaign?.current_version_id); + const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({ + settings, + campaignId, + version, + locked, + reload, + setError, + currentStep: "campaign-fields", + unsavedTitle: "Unsaved fields", + unsavedMessage: "Campaign fields have unsaved changes. Save them before leaving, or discard them and continue.", + transformLoadedDraft: (loadedVersion, loadedDraft) => migrateFieldOverridePolicy(loadedDraft, asRecord(loadedVersion.editor_state)), + onLoaded: (_loadedVersion, loadedDraft) => { + fieldValueKeys.current = normalizeFields(loadedDraft.fields).map((field) => field.name); + } + }); + const fields = useMemo(() => normalizeFields(displayDraft.fields), [displayDraft.fields]); + const globalValues = asRecord(displayDraft.global_values); + const fieldNameWarning = useMemo(() => describeFieldNameProblem(fields), [fields]); + const canSave = dirty && !locked && Boolean(draft) && !fieldNameWarning; + + + function patchDraft(path: string[], value: unknown) { + if (locked) return; + setDraft((current) => updateNested(current ?? {}, path, value)); + markDirty(); + } + + function patchFields(nextFields: CampaignFieldDefinition[]) { + patchDraft(["fields"], nextFields.map((field) => ({ + name: field.name, + type: field.type || "string", + label: field.label, + required: field.required, + can_override: field.can_override + }))); + } + + function patchGlobalValues(nextValues: Record) { + patchDraft(["global_values"], nextValues); + } + + + function setField(index: number, patchValue: Partial) { + const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, ...patchValue } : field); + patchFields(nextFields); + } + + function renameField(index: number, nextName: string) { + const oldName = fields[index]?.name ?? ""; + const cleanedName = nextName.trim(); + const duplicate = Boolean(cleanedName) && fields.some((field, currentIndex) => currentIndex !== index && field.name === cleanedName); + const valueKey = fieldValueKeys.current[index] ?? oldName; + const nextFields = fields.map((field, currentIndex) => currentIndex === index ? { ...field, name: cleanedName } : field); + const nextGlobalValues = { ...globalValues }; + + if (!duplicate && cleanedName) { + if (valueKey && valueKey !== cleanedName && Object.prototype.hasOwnProperty.call(nextGlobalValues, valueKey)) { + nextGlobalValues[cleanedName] = nextGlobalValues[valueKey]; + delete nextGlobalValues[valueKey]; + } + fieldValueKeys.current[index] = cleanedName; + } + + setDraft((current) => { + const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); + const nextWithGlobalValues = updateNested(nextDraft, ["global_values"], nextGlobalValues); + if (duplicate || !cleanedName || !valueKey || valueKey === cleanedName) { + return nextWithGlobalValues; + } + return migrateEntryFieldValues(nextWithGlobalValues, valueKey, cleanedName); + }); + markDirty(); + } + + function addField(afterIndex = fields.length - 1) { + const name = uniqueFieldName(fields); + const nextField = { name, label: humanizeFieldName(name), type: "string", required: false, can_override: true }; + const nextFields = insertAfter(fields, afterIndex, nextField); + fieldValueKeys.current = insertAfter(fieldValueKeys.current, afterIndex, name); + const nextGlobalValues = { ...globalValues, [name]: "" }; + setDraft((current) => { + const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); + return updateNested(nextDraft, ["global_values"], nextGlobalValues); + }); + markDirty(); + } + + function moveField(index: number, targetIndex: number) { + if (locked || index === targetIndex) return; + fieldValueKeys.current = moveArrayItem(fieldValueKeys.current, index, targetIndex); + patchFields(moveArrayItem(fields, index, targetIndex)); + } + + function deleteField(index: number) { + const field = fields[index]; + const nextFields = fields.filter((_, currentIndex) => currentIndex !== index); + fieldValueKeys.current = fieldValueKeys.current.filter((_, currentIndex) => currentIndex !== index); + const nextGlobalValues = { ...globalValues }; + if (field?.name) { + delete nextGlobalValues[field.name]; + } + setDraft((current) => { + const nextDraft = updateNested(current ?? {}, ["fields"], nextFields); + return updateNested(nextDraft, ["global_values"], nextGlobalValues); + }); + markDirty(); + } + + function setGlobalValue(key: string, value: unknown) { + patchGlobalValues({ ...globalValues, [key]: value }); + } + + function setOverrideAllowed(index: number, allowed: boolean) { + setField(index, { can_override: allowed }); + } + + async function saveFields(): Promise { + const fieldProblem = describeFieldNameProblem(fields); + if (fieldProblem) { + setLocalError(fieldProblem); + setSaveState("Save blocked"); + return false; + } + return saveDraft("manual"); + } + + + return ( +
+
+
+ Fields + +
+
+ + +
+
+ + {error && {error}} + {localError && {localError}} + {fieldNameWarning && {fieldNameWarning}} + {locked && } + + + <> +
+ `field-row-${index}`} + emptyText="No campaign fields configured yet." + emptyAction={ addField(-1)} disabled={locked} label="Add first field" />} + className="field-editor-table-wrap field-editor-table" + /> +
+ +
+ +
+ +
+
+ ); +} + +type FieldColumnContext = { + locked: boolean; + fields: CampaignFieldDefinition[]; + globalValues: Record; + renameField: (index: number, nextName: string) => void; + setField: (index: number, patchValue: Partial) => void; + setGlobalValue: (key: string, value: unknown) => void; + setOverrideAllowed: (index: number, allowed: boolean) => void; + addField: (afterIndex?: number) => void; + moveField: (index: number, targetIndex: number) => void; + deleteField: (index: number) => void; +}; + +function fieldColumns({ locked, fields, globalValues, renameField, setField, setGlobalValue, setOverrideAllowed, addField, moveField, deleteField }: FieldColumnContext): DataGridColumn[] { + return [ + { id: "name", header: "Field ID", width: 190, resizable: true, sortable: true, filterable: true, sticky: "start", render: (field, index) => renameField(index, event.target.value)} />, value: (field) => field.name }, + { id: "label", header: "Label", width: 210, resizable: true, sortable: true, filterable: true, render: (field, index) => setField(index, { label: event.target.value })} />, value: (field) => field.label }, + { id: "type", header: "Type", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: fieldTypeOptions.map((option) => ({ value: option, label: option })) }, render: (field, index) => , value: (field) => field.type }, + { id: "required", header: "Required", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "required", label: "Required" }, { value: "optional", label: "Optional" }] }, render: (field, index) => setField(index, { required: checked })} />, value: (field) => field.required ? "required" : "optional" }, + { id: "global_value", header: "Global value", width: 220, resizable: true, filterable: true, render: (field) => setGlobalValue(field.name, value)} />, value: (field) => String(globalValues[field.name] ?? "") }, + { id: "override", header: "Recipient override", width: 170, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "can override", label: "Can override" }, { value: "locked", label: "Global only" }] }, render: (field, index) => setOverrideAllowed(index, checked)} />, value: (field) => field.can_override ? "can override" : "locked" }, + { + id: "actions", + header: "Actions", + width: 180, + sticky: "end", + render: (_field, index) => ( + addField(index)} + onRemove={() => deleteField(index)} + onMoveUp={index > 0 ? () => moveField(index, index - 1) : undefined} + onMoveDown={index < fields.length - 1 ? () => moveField(index, index + 1) : undefined} + addLabel="Add field below" + removeLabel="Remove field" + moveUpLabel="Move field up" + moveDownLabel="Move field down" + /> + ) + } + ]; +} + +function normalizeFields(value: unknown): CampaignFieldDefinition[] { + if (!Array.isArray(value)) return []; + return value.filter(isRecord).map((field) => ({ + name: getText(field, "name"), + label: getText(field, "label"), + type: normalizeFieldType(getText(field, "type")), + required: getBool(field, "required"), + can_override: getBool(field, "can_override", true) + })); +} + + +function migrateEntryFieldValues(draft: Record, oldName: string, newName: string): Record { + const entries = asRecord(draft.entries); + const inlineEntries = Array.isArray(entries.inline) ? entries.inline : []; + if (inlineEntries.length === 0) return draft; + + const nextInlineEntries = inlineEntries.map((entry) => { + if (!isRecord(entry)) return entry; + const fields = asRecord(entry.fields); + if (!Object.prototype.hasOwnProperty.call(fields, oldName)) return entry; + + const nextFields = { ...fields }; + if (!Object.prototype.hasOwnProperty.call(nextFields, newName)) { + nextFields[newName] = nextFields[oldName]; + } + delete nextFields[oldName]; + return { ...entry, fields: nextFields }; + }); + + return updateNested(draft, ["entries", "inline"], nextInlineEntries); +} + +function migrateFieldOverridePolicy(draft: Record, editorState: Record): Record { + const overridePolicy = asRecord(editorState.field_overrides); + if (Object.keys(overridePolicy).length === 0) return draft; + + const fields = normalizeFields(draft.fields).map((field) => { + if (!field.name || !Object.prototype.hasOwnProperty.call(overridePolicy, field.name)) return field; + return { ...field, can_override: getBool(overridePolicy, field.name, true) }; + }); + + return updateNested(draft, ["fields"], fields); +} + +function describeFieldNameProblem(fields: CampaignFieldDefinition[]): string { + const names = fields.map((field) => field.name.trim()); + if (names.some((name) => !name)) { + return "Field IDs must not be empty before saving."; + } + + const seen = new Set(); + const duplicates = new Set(); + for (const name of names) { + if (seen.has(name)) duplicates.add(name); + seen.add(name); + } + + if (duplicates.size === 0) return ""; + return `Duplicate field ID${duplicates.size === 1 ? "" : "s"}: ${[...duplicates].sort().join(", ")}. Field IDs must be unique before saving.`; +} + +function uniqueFieldName(fields: CampaignFieldDefinition[]): string { + const existing = new Set(fields.map((field) => field.name)); + let counter = fields.length + 1; + let name = `field_${counter}`; + while (existing.has(name)) { + counter += 1; + name = `field_${counter}`; + } + return name; +} diff --git a/webui/src/features/campaigns/CampaignJsonView.tsx b/webui/src/features/campaigns/CampaignJsonView.tsx new file mode 100644 index 0000000..a98bf72 --- /dev/null +++ b/webui/src/features/campaigns/CampaignJsonView.tsx @@ -0,0 +1,39 @@ +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 VersionLine from "./components/VersionLine"; +import LoadingFrame from "../../components/LoadingFrame"; +import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; +import { asRecord, getCampaignJson } from "./utils/campaignView"; +import { downloadJson, safeFileStem } from "./utils/draftEditor"; + +export default function CampaignJsonView({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { + const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId); + const version = data.currentVersion; + const campaignJson = getCampaignJson(version); + const campaign = asRecord(campaignJson.campaign); + const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`; + + return ( +
+
+
+ JSON + +
+
+ + +
+
+ {error && {error}} + + + {!loading || version ?
{JSON.stringify(campaignJson, null, 2)}
:
{"{}"}
} +
+
+
+ ); +} diff --git a/webui/src/features/campaigns/CampaignListPage.tsx b/webui/src/features/campaigns/CampaignListPage.tsx new file mode 100644 index 0000000..00afc99 --- /dev/null +++ b/webui/src/features/campaigns/CampaignListPage.tsx @@ -0,0 +1,191 @@ +import { useEffect, useState } from "react"; +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 DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; +import { createNewCampaign, listCampaigns } from "../../api/campaigns"; +import type { CampaignListItem } from "../../types"; + +export default function CampaignListPage({ settings }: { settings: ApiSettings }) { + const navigate = useNavigate(); + const [campaigns, setCampaigns] = useState([]); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [lastLoadedAt, setLastLoadedAt] = useState(""); + + async function load() { + setLoading(true); + setError(""); + try { + const data = await listCampaigns(settings); + setCampaigns(data); + setLastLoadedAt(formatLoadedAt(new Date())); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + } + + async function create() { + setCreating(true); + setError(""); + try { + const created = await createNewCampaign(settings); + navigate(`/campaigns/${created.campaign.id}/wizard/create`); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setCreating(false); + } + } + + useEffect(() => { + load(); + }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); + + const columns: DataGridColumn[] = [ + { + id: "campaign", + header: "Campaign", + width: "minmax(260px, 1.8fr)", + sortable: true, + filterable: true, + sticky: "start", + render: (campaign) => ( +
+ + {campaign.name || campaign.external_id || campaign.id} + +
{campaign.description || campaign.external_id || campaign.id}
+
+ ), + value: (campaign) => `${campaign.name ?? ""} ${campaign.external_id ?? ""} ${campaign.id}` + }, + { + id: "status", + header: "Status", + width: 150, + sortable: true, + filterable: true, + columnType: "from-list", + list: { + options: [ + { value: "draft", label: "Draft" }, + { value: "active", label: "Active" }, + { value: "completed", label: "Completed" }, + { value: "archived", label: "Archived" } + ], + display: "pill" + }, + render: (campaign) => , + value: (campaign) => campaign.status || "draft" + }, + { + id: "current_version", + header: "Current version", + width: 170, + sortable: true, + filterable: true, + className: "version-cell mono-small", + render: (campaign) => {campaign.current_version_id ? shortId(campaign.current_version_id) : "—"}, + value: (campaign) => campaign.current_version_id ?? "" + }, + { + id: "updated", + header: "Updated", + width: 190, + sortable: true, + filterable: true, + filterType: "date", + className: "updated-cell", + render: (campaign) => formatDateTime(campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at), + value: (campaign) => campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at ?? "" + }, + { + id: "actions", + header: "Actions", + width: 110, + sticky: "end", + render: (campaign) => + } + ]; + + return ( +
+ {error && {error}} + +
+
+ All campaigns +

{lastLoadedAt ? `Last loaded: ${lastLoadedAt}` : "Not loaded yet"}

+
+
+ + +
+
+ + + + {campaigns.length === 0 ? ( +
+

No campaigns yet

+

Start with a guided campaign draft. The WebUI will create a portable campaign JSON in the background.

+ +
+ ) : ( + campaign.id} + initialSort={{ columnId: "updated", direction: "desc" }} + className="campaign-table-wrap" + emptyText="No campaigns found." + /> + )} +
+
+
+ ); +} + +function shortId(value: string): string { + if (value.length <= 20) return value; + return `${value.slice(0, 12)}…${value.slice(-6)}`; +} + +function formatDateTime(value?: string): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }); +} + +function formatLoadedAt(value: Date): string { + return value.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); +} diff --git a/webui/src/features/campaigns/CampaignOverviewPage.tsx b/webui/src/features/campaigns/CampaignOverviewPage.tsx new file mode 100644 index 0000000..78d1d7c --- /dev/null +++ b/webui/src/features/campaigns/CampaignOverviewPage.tsx @@ -0,0 +1,281 @@ +import { useEffect, useMemo, useState } from "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 DataGrid, { type DataGridColumn } from "../../components/table/DataGrid"; +import { + lockCampaignVersionPermanently, + lockCampaignVersionTemporarily, + unlockCampaignVersionUserLock, + updateCampaignMetadata, + type CampaignVersionListItem, +} from "../../api/campaigns"; +import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; +import { + canUnlockValidationVersion, + formatDateTime, + isFinalLockedVersion, + isPermanentUserLockedVersion, + isTemporaryUserLockedVersion, + isVersionReadyForDelivery, + summaryValue, +} from "./utils/campaignView"; + +const campaignModeOptions = ["draft", "test", "send"]; +type LockAction = "temporary" | "unlock" | "permanent"; +type PendingLockAction = { version: CampaignVersionListItem; action: LockAction } | null; + +export default function CampaignOverviewPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) { + const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true }); + const campaign = data.campaign; + const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]); + const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" }); + const [identityDirty, setIdentityDirty] = useState(false); + const [savingIdentity, setSavingIdentity] = useState(false); + const [pendingLockAction, setPendingLockAction] = useState(null); + const [lockBusy, setLockBusy] = useState(false); + const [message, setMessage] = useState(""); + + useEffect(() => { + if (!campaign || identityDirty) return; + setIdentity({ + external_id: campaign.external_id ?? "", + name: campaign.name ?? "", + status: campaign.status ?? "", + description: campaign.description ?? "", + }); + }, [campaign, identityDirty]); + + function patchIdentity(key: keyof typeof identity, value: string) { + setIdentity((current) => ({ ...current, [key]: value })); + setIdentityDirty(true); + setMessage(""); + } + + async function saveIdentity() { + if (!campaign || savingIdentity || !identityDirty) return; + setSavingIdentity(true); + setError(""); + setMessage(""); + try { + await updateCampaignMetadata(settings, campaign.id, { + external_id: identity.external_id, + name: identity.name, + status: identity.status, + description: identity.description, + }); + setIdentityDirty(false); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingIdentity(false); + } + } + + async function applyLockAction() { + const pending = pendingLockAction; + if (!pending || lockBusy) return; + setLockBusy(true); + setError(""); + setMessage(""); + try { + if (pending.action === "temporary") { + await lockCampaignVersionTemporarily(settings, campaignId, pending.version.id); + setMessage(`Version #${pending.version.version_number} temporarily locked.`); + } else if (pending.action === "unlock") { + await unlockCampaignVersionUserLock(settings, campaignId, pending.version.id); + setMessage(`Temporary lock removed from version #${pending.version.version_number}.`); + } else { + await lockCampaignVersionPermanently(settings, campaignId, pending.version.id); + setMessage(`Version #${pending.version.version_number} permanently locked.`); + } + setPendingLockAction(null); + await reload(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLockBusy(false); + } + } + + return ( +
+
+
+ {campaign?.name || "Overview"} +

Campaign overview · version-independent identity and version history

+
+
+ + + +
+
+ + {error && {error}} + {message && {message}} + + +
+ + + + +
+ + +
+ + patchIdentity("external_id", event.target.value)} /> + + + + + + patchIdentity("name", event.target.value)} /> + + +