Compare commits
14 Commits
v0.1.8
...
78f52a36d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 78f52a36d4 | |||
| 0c4f198802 | |||
| c71fa3dc64 | |||
| 0a6064ec62 | |||
| fce6dd1138 | |||
| 4b9eb79065 | |||
| 7ea5bdb217 | |||
| f755cdf48d | |||
| 8965b27517 | |||
| 12036b1f36 | |||
| ad34365f6c | |||
| 724ca779d6 | |||
| 2e593b7fa4 | |||
| 3f0b14a726 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -332,6 +332,7 @@ cython_debug/
|
|||||||
webui/.policy-test-build/
|
webui/.policy-test-build/
|
||||||
webui/.template-preview-test-build/
|
webui/.template-preview-test-build/
|
||||||
webui/.import-test-build/
|
webui/.import-test-build/
|
||||||
|
webui/.review-preview-test-build/
|
||||||
|
|
||||||
# GovOPlaN shared ignore rules from govoplan-core
|
# GovOPlaN shared ignore rules from govoplan-core
|
||||||
# Local WebUI test/build scratch directories
|
# Local WebUI test/build scratch directories
|
||||||
@@ -340,6 +341,7 @@ webui/.import-test-build/
|
|||||||
.policy-test-build/
|
.policy-test-build/
|
||||||
.template-preview-test-build/
|
.template-preview-test-build/
|
||||||
.import-test-build/
|
.import-test-build/
|
||||||
|
.review-preview-test-build/
|
||||||
webui/.component-test-build/
|
webui/.component-test-build/
|
||||||
webui/.module-test-build/
|
webui/.module-test-build/
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ This repository owns:
|
|||||||
- campaign/version/job/issue/send-attempt/append-attempt models and migrations
|
- campaign/version/job/issue/send-attempt/append-attempt models and migrations
|
||||||
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
|
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
|
||||||
- WebUI package `@govoplan/campaign-webui`
|
- WebUI package `@govoplan/campaign-webui`
|
||||||
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, `/address-book`, and `/templates`
|
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, and `/templates`
|
||||||
|
|
||||||
Core owns the auth facade, RBAC/capability contracts, database/session
|
Core owns the auth facade, RBAC/capability contracts, database/session
|
||||||
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
|
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Recipient And Address Management Boundary
|
# Recipient And Address Management Boundary
|
||||||
|
|
||||||
Campaigns currently own campaign-local recipient entries because sending and
|
Campaigns own campaign-local recipient entries because sending and reporting
|
||||||
reporting need a frozen recipient snapshot. Long-lived address management is a
|
need a frozen recipient snapshot. Long-lived address management is a separate
|
||||||
separate domain and should move to `govoplan-addresses`.
|
domain owned by `govoplan-addresses`.
|
||||||
|
|
||||||
## `govoplan-campaign` Owns
|
## `govoplan-campaign` Owns
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ separate domain and should move to `govoplan-addresses`.
|
|||||||
Campaign data is immutable once a version is built for sending. Later address
|
Campaign data is immutable once a version is built for sending. Later address
|
||||||
book changes must not rewrite historical campaign evidence.
|
book changes must not rewrite historical campaign evidence.
|
||||||
|
|
||||||
## `govoplan-addresses` Should Own
|
## `govoplan-addresses` Owns
|
||||||
|
|
||||||
- Adrema-style address management
|
- Adrema-style address management
|
||||||
- reusable person, organization, household, and postal-address records
|
- reusable person, organization, household, and postal-address records
|
||||||
@@ -27,15 +27,25 @@ book changes must not rewrite historical campaign evidence.
|
|||||||
- import/export of reusable address directories
|
- import/export of reusable address directories
|
||||||
- address quality checks and change history
|
- address quality checks and change history
|
||||||
|
|
||||||
The addresses module should provide stable DTOs and capabilities that campaigns,
|
The addresses module provides the UI/module boundary for this domain and should
|
||||||
mail, forms, reporting, portal, and postbox modules can consume without direct
|
grow stable DTOs and capabilities that campaigns, mail, forms, reporting,
|
||||||
imports.
|
portal, and postbox modules can consume without direct imports.
|
||||||
|
|
||||||
## Integration Contract
|
## Current Integration Contract
|
||||||
|
|
||||||
The campaign module should ask the platform whether the addresses module is
|
The campaign module asks the platform registry for address capabilities and
|
||||||
installed. When present, campaign can offer address-source choices through a
|
keeps working when they are absent. It must not import `govoplan-addresses`
|
||||||
capability such as `addresses.recipientSource`.
|
ORM models, services, or WebUI components.
|
||||||
|
|
||||||
|
When `addresses.lookup` is available, campaign recipient address fields use it
|
||||||
|
for autocomplete. The campaign page may warm a small suggestion cache with an
|
||||||
|
empty query and then query by typed text as the user edits sender, reply-to,
|
||||||
|
global recipient, or per-recipient address fields.
|
||||||
|
|
||||||
|
When `addresses.recipient_source` is available, campaign offers a separate
|
||||||
|
address-book/list import flow. This is for importing reusable recipient sources
|
||||||
|
into the campaign recipient table; normal one-off address entry still happens
|
||||||
|
inside the address fields while typing.
|
||||||
|
|
||||||
The capability should return snapshots, not live ORM objects:
|
The capability should return snapshots, not live ORM objects:
|
||||||
|
|
||||||
@@ -49,6 +59,11 @@ Campaign stores the resolved snapshot in the campaign version. It may keep a
|
|||||||
reference to the address source for traceability, but the built campaign remains
|
reference to the address source for traceability, but the built campaign remains
|
||||||
auditable even if the address source changes later.
|
auditable even if the address source changes later.
|
||||||
|
|
||||||
|
If the source revision changes after import, campaign shows a stale-source
|
||||||
|
warning and lets the user reopen the import dialog with that source preselected.
|
||||||
|
The user still chooses append or replace; campaign should not silently rewrite
|
||||||
|
recipient rows.
|
||||||
|
|
||||||
## Non-Goals For Campaign
|
## Non-Goals For Campaign
|
||||||
|
|
||||||
Campaign should not become the global address book. It should not own:
|
Campaign should not become the global address book. It should not own:
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Recipient Import Guide
|
# Recipient Import Guide
|
||||||
|
|
||||||
Recipient import lets campaign authors turn spreadsheet-like source data into
|
Recipient import lets campaign authors turn spreadsheet-like source data into
|
||||||
campaign-local recipient entries. It is intentionally campaign-local today:
|
campaign-local recipient entries. It is intentionally campaign-local:
|
||||||
reusable address books and Adrema-style address management belong in the future
|
reusable address books and Adrema-style address management belong in the
|
||||||
`govoplan-addresses` module.
|
`govoplan-addresses` module.
|
||||||
|
|
||||||
## Supported Inputs
|
## Supported Inputs
|
||||||
@@ -66,7 +66,7 @@ Administrators should:
|
|||||||
|
|
||||||
- define naming conventions for recurring mapping profiles
|
- define naming conventions for recurring mapping profiles
|
||||||
- verify that imported fields have a lawful processing basis for the campaign
|
- verify that imported fields have a lawful processing basis for the campaign
|
||||||
- keep reusable address-directory ownership out of campaigns until
|
- keep reusable address-directory ownership out of campaigns because
|
||||||
`govoplan-addresses` owns that domain
|
`govoplan-addresses` owns that domain
|
||||||
- use campaign reports and audit evidence to trace which source produced which
|
- use campaign reports and audit evidence to trace which source produced which
|
||||||
recipient entries
|
recipient entries
|
||||||
|
|||||||
@@ -390,6 +390,27 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
|
||||||
|
return config.attachments.send_without_attachments_behavior or (
|
||||||
|
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
|
||||||
|
messages = {
|
||||||
|
Behavior.BLOCK: "No attachment file was resolved for this message, and campaign policy blocks sending without attachments.",
|
||||||
|
Behavior.ASK: "No attachment file was resolved for this message. Confirm the message should still be sent.",
|
||||||
|
Behavior.DROP: "No attachment file was resolved for this message. Campaign policy excludes messages without attachments.",
|
||||||
|
Behavior.WARN: "No attachment file was resolved for this message. Campaign policy allows sending with a warning.",
|
||||||
|
}
|
||||||
|
return AttachmentIssue(
|
||||||
|
severity=ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING,
|
||||||
|
code="missing_attachment_coverage",
|
||||||
|
message=messages.get(behavior, "No attachment file was resolved for this message."),
|
||||||
|
behavior=behavior,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_one_config(
|
def _resolve_one_config(
|
||||||
*,
|
*,
|
||||||
campaign_file: str | Path,
|
campaign_file: str | Path,
|
||||||
@@ -495,6 +516,15 @@ def resolve_entry_attachments(
|
|||||||
)
|
)
|
||||||
|
|
||||||
issues = [issue for item in resolved for issue in item.issues]
|
issues = [issue for item in resolved for issue in item.issues]
|
||||||
|
missing_coverage_behavior = _send_without_attachments_behavior(config)
|
||||||
|
if (
|
||||||
|
entry.active
|
||||||
|
and resolved
|
||||||
|
and missing_coverage_behavior != Behavior.CONTINUE
|
||||||
|
and sum(len(item.matches) for item in resolved) == 0
|
||||||
|
and not any(issue.behavior == Behavior.BLOCK for issue in issues)
|
||||||
|
):
|
||||||
|
issues.append(_issue_for_missing_attachment_coverage(missing_coverage_behavior))
|
||||||
return EntryAttachmentResolution(
|
return EntryAttachmentResolution(
|
||||||
entry_index=entry_index,
|
entry_index=entry_index,
|
||||||
entry_id=entry.id,
|
entry_id=entry.id,
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
from govoplan_core.mail.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials, TransportSecurity
|
from govoplan_core.mail.config import (
|
||||||
|
ImapConfig,
|
||||||
|
ImapServerConfig,
|
||||||
|
SmtpConfig,
|
||||||
|
SmtpServerConfig,
|
||||||
|
TransportCredentials,
|
||||||
|
normalize_split_transport_credentials,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StrictModel(BaseModel):
|
class StrictModel(BaseModel):
|
||||||
@@ -124,28 +131,7 @@ class ServerConfig(StrictModel):
|
|||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_legacy_credentials(cls, value: Any) -> Any:
|
def normalize_legacy_credentials(cls, value: Any) -> Any:
|
||||||
if not isinstance(value, dict):
|
return normalize_split_transport_credentials(value)
|
||||||
return value
|
|
||||||
data = dict(value)
|
|
||||||
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
|
|
||||||
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
|
|
||||||
for protocol in ("smtp", "imap"):
|
|
||||||
transport = data.get(protocol)
|
|
||||||
if not isinstance(transport, dict):
|
|
||||||
continue
|
|
||||||
next_transport = dict(transport)
|
|
||||||
next_credentials = dict(credentials.get(protocol) or {})
|
|
||||||
for field in ("username", "password"):
|
|
||||||
if field in next_transport and field not in next_credentials:
|
|
||||||
next_credentials[field] = next_transport[field]
|
|
||||||
next_transport.pop(field, None)
|
|
||||||
next_transport.pop("enabled", None)
|
|
||||||
data[protocol] = next_transport
|
|
||||||
if next_credentials:
|
|
||||||
credentials[protocol] = next_credentials
|
|
||||||
if credentials:
|
|
||||||
data["credentials"] = credentials
|
|
||||||
return data
|
|
||||||
|
|
||||||
def runtime_smtp_config(self) -> SmtpConfig | None:
|
def runtime_smtp_config(self) -> SmtpConfig | None:
|
||||||
if self.smtp is None:
|
if self.smtp is None:
|
||||||
@@ -418,11 +404,20 @@ class AttachmentsConfig(StrictModel):
|
|||||||
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
|
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
|
||||||
allow_individual: bool = False
|
allow_individual: bool = False
|
||||||
send_without_attachments: bool = True
|
send_without_attachments: bool = True
|
||||||
|
send_without_attachments_behavior: Behavior | None = None
|
||||||
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
|
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
|
||||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||||
missing_behavior: Behavior = Behavior.ASK
|
missing_behavior: Behavior = Behavior.ASK
|
||||||
ambiguous_behavior: Behavior = Behavior.ASK
|
ambiguous_behavior: Behavior = Behavior.ASK
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
|
||||||
|
if self.send_without_attachments_behavior is None:
|
||||||
|
self.send_without_attachments_behavior = Behavior.CONTINUE if self.send_without_attachments else Behavior.BLOCK
|
||||||
|
else:
|
||||||
|
self.send_without_attachments = self.send_without_attachments_behavior != Behavior.BLOCK
|
||||||
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def individual_base_path_values(self) -> set[str]:
|
def individual_base_path_values(self) -> set[str]:
|
||||||
return {base_path.path for base_path in self.base_paths if base_path.allow_individual}
|
return {base_path.path for base_path in self.base_paths if base_path.allow_individual}
|
||||||
@@ -501,7 +496,11 @@ class ImportProvenance(StrictModel):
|
|||||||
id: str
|
id: str
|
||||||
imported_at: str
|
imported_at: str
|
||||||
mode: Literal["append", "replace"]
|
mode: Literal["append", "replace"]
|
||||||
source_type: Literal["csv", "xlsx", "text"]
|
source_type: Literal["csv", "xlsx", "text", "addresses"]
|
||||||
|
source_id: str | None = None
|
||||||
|
source_label: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
source_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
filename: str | None = None
|
filename: str | None = None
|
||||||
sheet_name: str | None = None
|
sheet_name: str | None = None
|
||||||
encoding: str | None = None
|
encoding: str | None = None
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
@@ -8,7 +9,8 @@ from typing import Iterable
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from .field_values import ignored_entry_field_overrides
|
from .field_values import ignored_entry_field_overrides
|
||||||
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
|
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
|
||||||
|
from ..attachments.resolver import resolve_campaign_attachments
|
||||||
|
|
||||||
|
|
||||||
class Severity(StrEnum):
|
class Severity(StrEnum):
|
||||||
@@ -51,6 +53,13 @@ class SemanticReport(BaseModel):
|
|||||||
return self.error_count == 0
|
return self.error_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _EntriesValidation:
|
||||||
|
mode: str
|
||||||
|
count: int | None
|
||||||
|
issues: list[SemanticIssue]
|
||||||
|
|
||||||
|
|
||||||
def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue:
|
def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue:
|
||||||
return SemanticIssue(severity=severity, code=code, message=message, path=path)
|
return SemanticIssue(severity=severity, code=code, message=message, path=path)
|
||||||
|
|
||||||
@@ -200,57 +209,95 @@ def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
|||||||
|
|
||||||
def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||||
collection = config.attachments.zip
|
collection = config.attachments.zip
|
||||||
issues: list[SemanticIssue] = []
|
|
||||||
if not collection.enabled:
|
if not collection.enabled:
|
||||||
return issues
|
return []
|
||||||
if not collection.archives:
|
if not collection.archives:
|
||||||
return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")]
|
return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")]
|
||||||
|
|
||||||
|
issues, archive_ids, standard_count = _zip_archive_catalog_issues(config)
|
||||||
|
if standard_count != 1:
|
||||||
|
issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives"))
|
||||||
|
issues.extend(_zip_rule_selection_issues(config, archive_ids))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_archive_catalog_issues(config: CampaignConfig) -> tuple[list[SemanticIssue], set[str], int]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
archive_ids: set[str] = set()
|
archive_ids: set[str] = set()
|
||||||
archive_names: set[str] = set()
|
archive_names: set[str] = set()
|
||||||
standard_count = 0
|
standard_count = 0
|
||||||
field_definitions = {field.name: field for field in config.fields}
|
field_definitions = {field.name: field for field in config.fields}
|
||||||
for index, archive in enumerate(collection.archives):
|
for index, archive in enumerate(config.attachments.zip.archives):
|
||||||
path = f"/attachments/zip/archives/{index}"
|
path = f"/attachments/zip/archives/{index}"
|
||||||
|
issues.extend(_zip_archive_identity_issues(archive, path, archive_ids, archive_names))
|
||||||
|
if archive.standard:
|
||||||
|
standard_count += 1
|
||||||
|
issues.extend(_zip_password_issues(config, archive, path, field_definitions))
|
||||||
|
return issues, archive_ids, standard_count
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_archive_identity_issues(
|
||||||
|
archive: ZipArchiveConfig,
|
||||||
|
path: str,
|
||||||
|
archive_ids: set[str],
|
||||||
|
archive_names: set[str],
|
||||||
|
) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
if not archive.id.strip():
|
if not archive.id.strip():
|
||||||
issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id"))
|
issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id"))
|
||||||
elif archive.id in archive_ids:
|
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"))
|
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)
|
archive_ids.add(archive.id)
|
||||||
|
|
||||||
normalized_archive_name = _normalized_zip_archive_name(archive.name)
|
normalized_archive_name = _normalized_zip_archive_name(archive.name)
|
||||||
if not normalized_archive_name:
|
if not normalized_archive_name:
|
||||||
issues.append(_issue(Severity.ERROR, "zip_archive_name_missing", "ZIP archive has no filename", f"{path}/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:
|
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"))
|
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)
|
archive_names.add(normalized_archive_name)
|
||||||
if archive.standard:
|
return issues
|
||||||
standard_count += 1
|
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_password_issues(
|
||||||
|
config: CampaignConfig,
|
||||||
|
archive: ZipArchiveConfig,
|
||||||
|
path: str,
|
||||||
|
field_definitions: dict[str, object],
|
||||||
|
) -> list[SemanticIssue]:
|
||||||
if not archive.password_enabled:
|
if not archive.password_enabled:
|
||||||
continue
|
return []
|
||||||
if archive.password_mode == ZipPasswordMode.DIRECT:
|
if archive.password_mode == ZipPasswordMode.DIRECT:
|
||||||
if not (archive.password or ""):
|
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"))
|
return [_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password")]
|
||||||
continue
|
return []
|
||||||
if archive.password_mode == ZipPasswordMode.TEMPLATE:
|
if archive.password_mode == ZipPasswordMode.TEMPLATE:
|
||||||
if not (archive.password_template or ""):
|
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"))
|
return [_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template")]
|
||||||
continue
|
return []
|
||||||
|
return _zip_password_field_issues(config, archive, path, field_definitions)
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_password_field_issues(
|
||||||
|
config: CampaignConfig,
|
||||||
|
archive: ZipArchiveConfig,
|
||||||
|
path: str,
|
||||||
|
field_definitions: dict[str, object],
|
||||||
|
) -> list[SemanticIssue]:
|
||||||
field_name = (archive.password_field or "").strip()
|
field_name = (archive.password_field or "").strip()
|
||||||
field = field_definitions.get(field_name)
|
field = field_definitions.get(field_name)
|
||||||
if not 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"))
|
return [_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field")]
|
||||||
elif field is None:
|
if 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"))
|
return [_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field")]
|
||||||
elif field.type != FieldType.PASSWORD:
|
if getattr(field, "type", None) != 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"))
|
return [_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field")]
|
||||||
elif archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""):
|
if 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}"))
|
return [_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}")]
|
||||||
|
return []
|
||||||
|
|
||||||
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"))
|
|
||||||
|
|
||||||
|
def _zip_rule_selection_issues(config: CampaignConfig, archive_ids: set[str]) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
for path, rule, _is_individual in _iter_attachment_rules(config):
|
for path, rule, _is_individual in _iter_attachment_rules(config):
|
||||||
selection = (rule.zip.archive_id or ZipRuleMode.INHERIT.value).strip()
|
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:
|
if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value, ZipRuleMode.EXCLUDE.value} and selection not in archive_ids:
|
||||||
@@ -276,6 +323,268 @@ def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_pr
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> list[SemanticIssue]:
|
||||||
|
return [
|
||||||
|
_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"unknown_global_value",
|
||||||
|
f"global_values contains {key!r}, but it is not declared in fields",
|
||||||
|
f"/global_values/{key}",
|
||||||
|
)
|
||||||
|
for key in config.global_values
|
||||||
|
if declared_names and key not in declared_names
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
|
runtime_imap = config.server.runtime_imap_config()
|
||||||
|
if config.delivery.imap_append_sent.enabled:
|
||||||
|
issues.extend(_imap_delivery_issues(runtime_imap))
|
||||||
|
runtime_smtp = config.server.runtime_smtp_config()
|
||||||
|
if config.campaign.mode == "send" and not runtime_smtp:
|
||||||
|
issues.append(
|
||||||
|
_issue(
|
||||||
|
Severity.ERROR,
|
||||||
|
"missing_smtp_config",
|
||||||
|
"campaign mode is 'send', but no server.smtp configuration is present",
|
||||||
|
"/server/smtp",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if runtime_smtp:
|
||||||
|
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
|
||||||
|
if missing:
|
||||||
|
issues.append(
|
||||||
|
_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"incomplete_smtp_config",
|
||||||
|
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
|
||||||
|
"/server/smtp",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _imap_delivery_issues(runtime_imap: object | None) -> list[SemanticIssue]:
|
||||||
|
if runtime_imap is None:
|
||||||
|
return [
|
||||||
|
_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"delivery_imap_enabled_without_server_imap",
|
||||||
|
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
|
||||||
|
"/delivery/imap_append_sent/enabled",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
|
||||||
|
if not missing:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
_issue(
|
||||||
|
Severity.ERROR,
|
||||||
|
"incomplete_imap_config",
|
||||||
|
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
|
||||||
|
"/server/imap",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _entries_validation(
|
||||||
|
config: CampaignConfig,
|
||||||
|
*,
|
||||||
|
campaign_path: Path,
|
||||||
|
check_files: bool,
|
||||||
|
field_names: set[str],
|
||||||
|
field_definitions: dict[str, object],
|
||||||
|
) -> _EntriesValidation:
|
||||||
|
if config.entries.is_inline:
|
||||||
|
inline_entries = config.entries.inline or []
|
||||||
|
issues = _inline_entries_issues(config, inline_entries)
|
||||||
|
return _EntriesValidation(mode="inline", count=len(inline_entries), issues=issues)
|
||||||
|
issues = _external_entries_issues(
|
||||||
|
config,
|
||||||
|
campaign_path=campaign_path,
|
||||||
|
check_files=check_files,
|
||||||
|
field_names=field_names,
|
||||||
|
field_definitions=field_definitions,
|
||||||
|
)
|
||||||
|
source_type = config.entries.source.type.value if config.entries.source else "unknown"
|
||||||
|
return _EntriesValidation(mode=f"external:{source_type}", count=None, issues=issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _inline_entries_issues(config: CampaignConfig, inline_entries: list[EntryConfig]) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
|
if not inline_entries:
|
||||||
|
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
|
||||||
|
for index, entry in enumerate(inline_entries):
|
||||||
|
if entry.active:
|
||||||
|
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _external_entries_issues(
|
||||||
|
config: CampaignConfig,
|
||||||
|
*,
|
||||||
|
campaign_path: Path,
|
||||||
|
check_files: bool,
|
||||||
|
field_names: set[str],
|
||||||
|
field_definitions: dict[str, object],
|
||||||
|
) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
|
mapping = config.entries.mapping or {}
|
||||||
|
if not mapping:
|
||||||
|
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
|
||||||
|
issues.extend(_mapping_issues(mapping, field_names, field_definitions))
|
||||||
|
if config.entries.defaults:
|
||||||
|
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
|
||||||
|
if check_files and config.entries.source:
|
||||||
|
issues.extend(_entries_source_file_issues(config, campaign_path, mapping))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_issues(
|
||||||
|
mapping: dict[str, str],
|
||||||
|
field_names: set[str],
|
||||||
|
field_definitions: dict[str, object],
|
||||||
|
) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
|
for target in mapping:
|
||||||
|
if not _mapping_target_known(target, field_names):
|
||||||
|
issues.append(
|
||||||
|
_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"unknown_mapping_target",
|
||||||
|
f"mapping target {target!r} is not recognized by the current campaign model",
|
||||||
|
f"/entries/mapping/{target}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
field_name = _mapping_target_field_name(target)
|
||||||
|
field = field_definitions.get(field_name or "")
|
||||||
|
if field_name and field is not None and not getattr(field, "can_override", True):
|
||||||
|
issues.append(
|
||||||
|
_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"mapping_target_not_overridable",
|
||||||
|
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
|
||||||
|
f"/entries/mapping/{target}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _entries_source_file_issues(config: CampaignConfig, campaign_path: Path, mapping: dict[str, str]) -> list[SemanticIssue]:
|
||||||
|
source = config.entries.source
|
||||||
|
if source is None:
|
||||||
|
return []
|
||||||
|
source_path = _resolve(campaign_path, source.path)
|
||||||
|
if not source_path.exists():
|
||||||
|
return [
|
||||||
|
_issue(
|
||||||
|
Severity.ERROR,
|
||||||
|
"entries_source_not_found",
|
||||||
|
f"entries source file does not exist: {source_path}",
|
||||||
|
"/entries/source/path",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if source.type != SourceType.CSV or not source.has_header:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
header = _csv_header(source_path, source.delimiter, source.encoding)
|
||||||
|
except OSError as exc:
|
||||||
|
return [_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path")]
|
||||||
|
header_set = set(header or [])
|
||||||
|
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
|
||||||
|
if not missing_columns:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
_issue(
|
||||||
|
Severity.ERROR,
|
||||||
|
"mapping_columns_missing",
|
||||||
|
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
|
||||||
|
"/entries/mapping",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
|
issues.extend(_attachment_file_check_issues(config, campaign_path))
|
||||||
|
issues.extend(_attachment_resolution_check_issues(config, campaign_path))
|
||||||
|
issues.extend(_template_source_file_issues(config, campaign_path))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||||
|
if config.attachments.base_paths:
|
||||||
|
return [
|
||||||
|
_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"attachments_base_path_not_found",
|
||||||
|
f"attachment base path {base_path_config.name!r} does not exist: {_resolve(campaign_path, base_path_config.path)}",
|
||||||
|
f"/attachments/base_paths/{index}/path",
|
||||||
|
)
|
||||||
|
for index, base_path_config in enumerate(config.attachments.base_paths)
|
||||||
|
if not _resolve(campaign_path, base_path_config.path).exists()
|
||||||
|
]
|
||||||
|
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
|
||||||
|
if attachments_base_path.exists():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"attachments_base_path_not_found",
|
||||||
|
f"attachments.base_path does not exist: {attachments_base_path}",
|
||||||
|
"/attachments/base_path",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_resolution_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||||
|
try:
|
||||||
|
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
|
||||||
|
except Exception as exc:
|
||||||
|
return [
|
||||||
|
_issue(
|
||||||
|
Severity.ERROR,
|
||||||
|
"attachment_resolution_failed",
|
||||||
|
f"attachment rules could not be resolved: {exc}",
|
||||||
|
"/attachments",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
|
for entry in report.entries:
|
||||||
|
entry_path = f"/entries/{entry.entry_id or entry.entry_index}"
|
||||||
|
for issue in entry.issues:
|
||||||
|
if any(issue is attachment_issue for attachment in entry.attachments for attachment_issue in attachment.issues):
|
||||||
|
continue
|
||||||
|
issues.append(_issue(Severity(issue.severity.value), issue.code, issue.message, entry_path))
|
||||||
|
for attachment in entry.attachments:
|
||||||
|
attachment_path = (
|
||||||
|
f"/attachments/global/{attachment.index}"
|
||||||
|
if attachment.scope.value == "global"
|
||||||
|
else f"{entry_path}/attachments/{attachment.index}"
|
||||||
|
)
|
||||||
|
for issue in attachment.issues:
|
||||||
|
issues.append(_issue(Severity(issue.severity.value), issue.code, issue.message, attachment_path))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _template_source_file_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
|
for schema_path, raw_path in _iter_template_source_paths(config):
|
||||||
|
path = _resolve(campaign_path, raw_path)
|
||||||
|
if not path.exists():
|
||||||
|
issues.append(
|
||||||
|
_issue(
|
||||||
|
Severity.ERROR,
|
||||||
|
"template_source_not_found",
|
||||||
|
f"template source file does not exist: {path}",
|
||||||
|
schema_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def validate_campaign_config(
|
def validate_campaign_config(
|
||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
*,
|
*,
|
||||||
@@ -289,149 +598,29 @@ def validate_campaign_config(
|
|||||||
field_definitions = {field.name: field for field in config.fields}
|
field_definitions = {field.name: field for field in config.fields}
|
||||||
declared_names = set(field_definitions)
|
declared_names = set(field_definitions)
|
||||||
|
|
||||||
for key in config.global_values:
|
issues.extend(_global_value_issues(config, declared_names))
|
||||||
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(_attachment_path_issues(config))
|
||||||
issues.extend(_zip_configuration_issues(config))
|
issues.extend(_zip_configuration_issues(config))
|
||||||
|
issues.extend(_delivery_issues(config))
|
||||||
|
|
||||||
runtime_imap = config.server.runtime_imap_config()
|
entries = _entries_validation(
|
||||||
if config.delivery.imap_append_sent.enabled:
|
config,
|
||||||
if runtime_imap is None:
|
campaign_path=campaign_path,
|
||||||
issues.append(_issue(
|
check_files=check_files,
|
||||||
Severity.WARNING,
|
field_names=field_names,
|
||||||
"delivery_imap_enabled_without_server_imap",
|
field_definitions=field_definitions,
|
||||||
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
|
)
|
||||||
"/delivery/imap_append_sent/enabled",
|
issues.extend(entries.issues)
|
||||||
))
|
|
||||||
else:
|
|
||||||
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
|
|
||||||
if missing:
|
|
||||||
issues.append(_issue(
|
|
||||||
Severity.ERROR,
|
|
||||||
"incomplete_imap_config",
|
|
||||||
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
|
|
||||||
"/server/imap",
|
|
||||||
))
|
|
||||||
|
|
||||||
runtime_smtp = config.server.runtime_smtp_config()
|
|
||||||
if config.campaign.mode == "send" and not runtime_smtp:
|
|
||||||
issues.append(_issue(
|
|
||||||
Severity.ERROR,
|
|
||||||
"missing_smtp_config",
|
|
||||||
"campaign mode is 'send', but no server.smtp configuration is present",
|
|
||||||
"/server/smtp",
|
|
||||||
))
|
|
||||||
|
|
||||||
if runtime_smtp:
|
|
||||||
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
|
|
||||||
if missing:
|
|
||||||
issues.append(_issue(
|
|
||||||
Severity.WARNING,
|
|
||||||
"incomplete_smtp_config",
|
|
||||||
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
|
|
||||||
"/server/smtp",
|
|
||||||
))
|
|
||||||
|
|
||||||
if config.entries.is_inline:
|
|
||||||
inline_entries = config.entries.inline or []
|
|
||||||
entries_count = len(inline_entries)
|
|
||||||
entries_mode = "inline"
|
|
||||||
if entries_count == 0:
|
|
||||||
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
|
|
||||||
for index, entry in enumerate(inline_entries):
|
|
||||||
if entry.active:
|
|
||||||
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
|
|
||||||
else:
|
|
||||||
entries_count = None
|
|
||||||
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
|
|
||||||
mapping = config.entries.mapping or {}
|
|
||||||
if not mapping:
|
|
||||||
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
|
|
||||||
for target in mapping:
|
|
||||||
if not _mapping_target_known(target, field_names):
|
|
||||||
issues.append(_issue(
|
|
||||||
Severity.WARNING,
|
|
||||||
"unknown_mapping_target",
|
|
||||||
f"mapping target {target!r} is not recognized by the current campaign model",
|
|
||||||
f"/entries/mapping/{target}",
|
|
||||||
))
|
|
||||||
field_name = _mapping_target_field_name(target)
|
|
||||||
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
|
|
||||||
issues.append(_issue(
|
|
||||||
Severity.WARNING,
|
|
||||||
"mapping_target_not_overridable",
|
|
||||||
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
|
|
||||||
f"/entries/mapping/{target}",
|
|
||||||
))
|
|
||||||
if config.entries.defaults:
|
|
||||||
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
|
|
||||||
if check_files and config.entries.source:
|
|
||||||
source_path = _resolve(campaign_path, config.entries.source.path)
|
|
||||||
if not source_path.exists():
|
|
||||||
issues.append(_issue(
|
|
||||||
Severity.ERROR,
|
|
||||||
"entries_source_not_found",
|
|
||||||
f"entries source file does not exist: {source_path}",
|
|
||||||
"/entries/source/path",
|
|
||||||
))
|
|
||||||
elif config.entries.source.type == SourceType.CSV and config.entries.source.has_header:
|
|
||||||
try:
|
|
||||||
header = _csv_header(source_path, config.entries.source.delimiter, config.entries.source.encoding)
|
|
||||||
header_set = set(header or [])
|
|
||||||
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
|
|
||||||
if missing_columns:
|
|
||||||
issues.append(_issue(
|
|
||||||
Severity.ERROR,
|
|
||||||
"mapping_columns_missing",
|
|
||||||
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
|
|
||||||
"/entries/mapping",
|
|
||||||
))
|
|
||||||
except OSError as exc:
|
|
||||||
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
|
|
||||||
|
|
||||||
if check_files:
|
if check_files:
|
||||||
if config.attachments.base_paths:
|
issues.extend(_file_check_issues(config, campaign_path))
|
||||||
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(
|
report = SemanticReport(
|
||||||
campaign_id=config.campaign.id,
|
campaign_id=config.campaign.id,
|
||||||
campaign_name=config.campaign.name,
|
campaign_name=config.campaign.name,
|
||||||
issues=issues,
|
issues=issues,
|
||||||
entries_mode=entries_mode,
|
entries_mode=entries.mode,
|
||||||
entries_count=entries_count,
|
entries_count=entries.count,
|
||||||
attachments_base_path=_attachment_base_path_report_value(config),
|
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}",
|
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,
|
imap_append_enabled=config.delivery.imap_append_sent.enabled,
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import event, inspect
|
from sqlalchemy import event
|
||||||
from sqlalchemy.orm import Session as OrmSession
|
from sqlalchemy.orm import Session as OrmSession
|
||||||
|
|
||||||
from govoplan_core.core.change_sequence import record_change
|
from govoplan_core.core.change_sequence import record_change
|
||||||
|
from govoplan_core.core.sqlalchemy_change_tracking import (
|
||||||
|
ensure_object_id,
|
||||||
|
has_attr_changes,
|
||||||
|
object_state,
|
||||||
|
operation_for_object,
|
||||||
|
previous_value,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
CampaignIssue,
|
CampaignIssue,
|
||||||
@@ -85,10 +92,10 @@ def _record_campaign_change(session: OrmSession, campaign: Campaign) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _record_share_visibility_change(session: OrmSession, share: CampaignShare) -> None:
|
def _record_share_visibility_change(session: OrmSession, share: CampaignShare) -> None:
|
||||||
state = inspect(share)
|
state = object_state(share)
|
||||||
if not share.campaign_id:
|
if not share.campaign_id:
|
||||||
return
|
return
|
||||||
if not state.deleted and not state.pending and not _has_attr_changes(
|
if not state.deleted and not state.pending and not has_attr_changes(
|
||||||
state,
|
state,
|
||||||
("campaign_id", "target_type", "target_id", "permission", "revoked_at"),
|
("campaign_id", "target_type", "target_id", "permission", "revoked_at"),
|
||||||
):
|
):
|
||||||
@@ -271,12 +278,12 @@ def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppen
|
|||||||
|
|
||||||
|
|
||||||
def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) -> str | None:
|
def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) -> str | None:
|
||||||
state = inspect(obj)
|
state = object_state(obj)
|
||||||
if state.pending:
|
if state.pending:
|
||||||
return "created"
|
return "created"
|
||||||
if state.deleted:
|
if state.deleted:
|
||||||
return "deleted"
|
return "deleted"
|
||||||
if not _has_attr_changes(state, changed_attrs):
|
if not has_attr_changes(state, changed_attrs):
|
||||||
return None
|
return None
|
||||||
status_history = state.attrs.status.history
|
status_history = state.attrs.status.history
|
||||||
if status_history.has_changes() and any(value == "deleted" for value in status_history.added):
|
if status_history.has_changes() and any(value == "deleted" for value in status_history.added):
|
||||||
@@ -285,38 +292,11 @@ def _operation_for_campaign(obj: Campaign, *, changed_attrs: tuple[str, ...]) ->
|
|||||||
|
|
||||||
|
|
||||||
def _operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
|
def _operation_for_object(obj: object, *, changed_attrs: tuple[str, ...]) -> str | None:
|
||||||
state = inspect(obj)
|
return operation_for_object(obj, changed_attrs=changed_attrs)
|
||||||
if state.pending:
|
|
||||||
return "created"
|
|
||||||
if state.deleted:
|
|
||||||
return "deleted"
|
|
||||||
if not _has_attr_changes(state, changed_attrs):
|
|
||||||
return None
|
|
||||||
return "updated"
|
|
||||||
|
|
||||||
|
|
||||||
def _has_attr_changes(state: Any, attrs: tuple[str, ...]) -> bool:
|
|
||||||
return any(name in state.attrs and state.attrs[name].history.has_changes() for name in attrs)
|
|
||||||
|
|
||||||
|
|
||||||
def _previous_value(obj: object, attr_name: str) -> str | None:
|
|
||||||
state = inspect(obj)
|
|
||||||
if attr_name not in state.attrs:
|
|
||||||
return None
|
|
||||||
history = state.attrs[attr_name].history
|
|
||||||
if not history.has_changes() or not history.deleted:
|
|
||||||
return None
|
|
||||||
value = history.deleted[0]
|
|
||||||
return str(value) if value is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_id(obj: object) -> str:
|
def _ensure_id(obj: object) -> str:
|
||||||
resource_id = getattr(obj, "id", None)
|
return ensure_object_id(obj, new_uuid)
|
||||||
if resource_id:
|
|
||||||
return str(resource_id)
|
|
||||||
resource_id = new_uuid()
|
|
||||||
setattr(obj, "id", resource_id)
|
|
||||||
return resource_id
|
|
||||||
|
|
||||||
|
|
||||||
def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
|
def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
|
||||||
@@ -326,12 +306,12 @@ def _campaign_payload(campaign: Campaign | None) -> dict[str, Any]:
|
|||||||
"campaign_id": campaign.id,
|
"campaign_id": campaign.id,
|
||||||
"owner_user_id": campaign.owner_user_id,
|
"owner_user_id": campaign.owner_user_id,
|
||||||
"owner_group_id": campaign.owner_group_id,
|
"owner_group_id": campaign.owner_group_id,
|
||||||
"previous_owner_user_id": _previous_value(campaign, "owner_user_id"),
|
"previous_owner_user_id": previous_value(campaign, "owner_user_id"),
|
||||||
"previous_owner_group_id": _previous_value(campaign, "owner_group_id"),
|
"previous_owner_group_id": previous_value(campaign, "owner_group_id"),
|
||||||
"status": campaign.status,
|
"status": campaign.status,
|
||||||
"previous_status": _previous_value(campaign, "status"),
|
"previous_status": previous_value(campaign, "status"),
|
||||||
"current_version_id": campaign.current_version_id,
|
"current_version_id": campaign.current_version_id,
|
||||||
"previous_current_version_id": _previous_value(campaign, "current_version_id"),
|
"previous_current_version_id": previous_value(campaign, "current_version_id"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -161,8 +161,6 @@ class RecipientImportMappingProfile(Base, TimestampMixin):
|
|||||||
value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
|
value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False)
|
||||||
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
|
||||||
owner: Mapped["User"] = relationship("User")
|
|
||||||
|
|
||||||
|
|
||||||
class CampaignVersion(Base, TimestampMixin):
|
class CampaignVersion(Base, TimestampMixin):
|
||||||
__tablename__ = "campaign_versions"
|
__tablename__ = "campaign_versions"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from email import policy
|
from email import policy
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -19,6 +20,30 @@ class MockCampaignSendError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _MockSendOutcome:
|
||||||
|
row: dict[str, Any]
|
||||||
|
sent_count: int = 0
|
||||||
|
failed_count: int = 0
|
||||||
|
skipped_count: int = 0
|
||||||
|
imap_appended_count: int = 0
|
||||||
|
imap_failed_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _MockSendBatch:
|
||||||
|
results: list[dict[str, Any]]
|
||||||
|
sent_count: int = 0
|
||||||
|
failed_count: int = 0
|
||||||
|
skipped_count: int = 0
|
||||||
|
imap_appended_count: int = 0
|
||||||
|
imap_failed_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def attempted_count(self) -> int:
|
||||||
|
return self.sent_count + self.failed_count
|
||||||
|
|
||||||
|
|
||||||
def _mock_mailbox() -> Any | None:
|
def _mock_mailbox() -> Any | None:
|
||||||
return mail_integration().mock_mailbox()
|
return mail_integration().mock_mailbox()
|
||||||
|
|
||||||
@@ -116,6 +141,163 @@ def _can_mock_send(
|
|||||||
return False, f"Validation status is {message.validation_status.value}"
|
return False, f"Validation status is {message.validation_status.value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_send_batch(
|
||||||
|
*,
|
||||||
|
config: Any,
|
||||||
|
built_messages: list[Any],
|
||||||
|
mailbox: Any | None,
|
||||||
|
send: bool,
|
||||||
|
include_warnings: bool,
|
||||||
|
include_needs_review: bool,
|
||||||
|
append_sent: bool,
|
||||||
|
) -> _MockSendBatch:
|
||||||
|
batch = _MockSendBatch(results=[])
|
||||||
|
for built in built_messages:
|
||||||
|
outcome = _mock_send_message(
|
||||||
|
config=config,
|
||||||
|
built=built,
|
||||||
|
mailbox=mailbox,
|
||||||
|
send=send,
|
||||||
|
include_warnings=include_warnings,
|
||||||
|
include_needs_review=include_needs_review,
|
||||||
|
append_sent=append_sent,
|
||||||
|
)
|
||||||
|
batch.results.append(outcome.row)
|
||||||
|
batch.sent_count += outcome.sent_count
|
||||||
|
batch.failed_count += outcome.failed_count
|
||||||
|
batch.skipped_count += outcome.skipped_count
|
||||||
|
batch.imap_appended_count += outcome.imap_appended_count
|
||||||
|
batch.imap_failed_count += outcome.imap_failed_count
|
||||||
|
return batch
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_send_message(
|
||||||
|
*,
|
||||||
|
config: Any,
|
||||||
|
built: Any,
|
||||||
|
mailbox: Any | None,
|
||||||
|
send: bool,
|
||||||
|
include_warnings: bool,
|
||||||
|
include_needs_review: bool,
|
||||||
|
append_sent: bool,
|
||||||
|
) -> _MockSendOutcome:
|
||||||
|
draft = built.draft
|
||||||
|
row = _mock_send_row(draft)
|
||||||
|
can_send, skip_reason = _can_mock_send(
|
||||||
|
draft,
|
||||||
|
include_warnings=include_warnings,
|
||||||
|
include_needs_review=include_needs_review,
|
||||||
|
)
|
||||||
|
if not can_send or built.mime is None:
|
||||||
|
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
|
||||||
|
return _MockSendOutcome(row=row, skipped_count=1)
|
||||||
|
|
||||||
|
recipients = _recipient_emails(draft)
|
||||||
|
envelope_from = _envelope_from(draft)
|
||||||
|
if not recipients:
|
||||||
|
row.update({"status": "skipped", "message": "No envelope recipients"})
|
||||||
|
return _MockSendOutcome(row=row, skipped_count=1)
|
||||||
|
if not send:
|
||||||
|
row.update({
|
||||||
|
"status": "ready",
|
||||||
|
"message": f"Would send to {len(recipients)} recipient(s)",
|
||||||
|
"envelope_from": envelope_from,
|
||||||
|
"envelope_recipients": recipients,
|
||||||
|
})
|
||||||
|
return _MockSendOutcome(row=row)
|
||||||
|
return _send_mock_smtp_message(
|
||||||
|
config=config,
|
||||||
|
built=built,
|
||||||
|
mailbox=mailbox,
|
||||||
|
row=row,
|
||||||
|
recipients=recipients,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
append_sent=append_sent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_send_row(draft: MessageDraft) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"entry_index": draft.entry_index,
|
||||||
|
"entry_id": draft.entry_id,
|
||||||
|
"subject": draft.subject,
|
||||||
|
"validation_status": draft.validation_status.value,
|
||||||
|
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status),
|
||||||
|
"to": _message_addresses_payload(draft.to),
|
||||||
|
"attachments": _attachment_payloads(draft),
|
||||||
|
"issues": _issue_payloads(draft),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _send_mock_smtp_message(
|
||||||
|
*,
|
||||||
|
config: Any,
|
||||||
|
built: Any,
|
||||||
|
mailbox: Any | None,
|
||||||
|
row: dict[str, Any],
|
||||||
|
recipients: list[str],
|
||||||
|
envelope_from: str,
|
||||||
|
append_sent: bool,
|
||||||
|
) -> _MockSendOutcome:
|
||||||
|
try:
|
||||||
|
if mailbox is not None and mailbox.consume_fail_next_smtp():
|
||||||
|
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
|
||||||
|
rejected = _smtp_rejection_matches(recipients)
|
||||||
|
if rejected and len(rejected) == len(recipients):
|
||||||
|
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
|
||||||
|
|
||||||
|
accepted = [recipient for recipient in recipients if recipient not in rejected]
|
||||||
|
smtp_record = mailbox.record_smtp_delivery(
|
||||||
|
built.mime,
|
||||||
|
envelope_from=envelope_from,
|
||||||
|
envelope_recipients=accepted,
|
||||||
|
smtp_host="mock.smtp.local",
|
||||||
|
)
|
||||||
|
row.update({
|
||||||
|
"status": "sent",
|
||||||
|
"message": f"Mock SMTP captured as {smtp_record.id}",
|
||||||
|
"smtp_message_id": smtp_record.id,
|
||||||
|
"envelope_from": envelope_from,
|
||||||
|
"envelope_recipients": accepted,
|
||||||
|
"refused_recipients": rejected,
|
||||||
|
})
|
||||||
|
imap_appended, imap_failed = _append_mock_sent_message(config, built, mailbox, row, append_sent=append_sent)
|
||||||
|
return _MockSendOutcome(row=row, sent_count=1, imap_appended_count=imap_appended, imap_failed_count=imap_failed)
|
||||||
|
except Exception as exc:
|
||||||
|
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
|
||||||
|
return _MockSendOutcome(row=row, failed_count=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_mock_sent_message(
|
||||||
|
config: Any,
|
||||||
|
built: Any,
|
||||||
|
mailbox: Any | None,
|
||||||
|
row: dict[str, Any],
|
||||||
|
*,
|
||||||
|
append_sent: bool,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
if not append_sent:
|
||||||
|
return 0, 0
|
||||||
|
try:
|
||||||
|
if mailbox is not None and mailbox.consume_fail_next_imap():
|
||||||
|
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
|
||||||
|
folder = _mock_sent_folder(config)
|
||||||
|
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
|
||||||
|
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
|
||||||
|
return 1, 0
|
||||||
|
except Exception as exc:
|
||||||
|
row.update({"imap_status": "failed", "imap_error": str(exc)})
|
||||||
|
return 0, 1
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_sent_folder(config: Any) -> str:
|
||||||
|
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
|
||||||
|
return config.delivery.imap_append_sent.folder
|
||||||
|
if config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
|
||||||
|
return config.server.imap.sent_folder
|
||||||
|
return "Sent"
|
||||||
|
|
||||||
|
|
||||||
def run_mock_campaign_send(
|
def run_mock_campaign_send(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -158,7 +340,7 @@ def run_mock_campaign_send(
|
|||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||||
include_bytes=True,
|
include_bytes=True,
|
||||||
prefix="multimailer-mock-send-",
|
prefix="govoplan-mock-send-",
|
||||||
) as prepared:
|
) as prepared:
|
||||||
prepared_raw = load_campaign_json(prepared.path)
|
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)
|
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
|
||||||
@@ -166,86 +348,15 @@ def run_mock_campaign_send(
|
|||||||
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
|
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
|
||||||
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
|
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
|
||||||
|
|
||||||
send_results: list[dict[str, Any]] = []
|
send_batch = _mock_send_batch(
|
||||||
sent_count = 0
|
config=config,
|
||||||
failed_count = 0
|
built_messages=build_result.built_messages,
|
||||||
skipped_count = 0
|
mailbox=mailbox,
|
||||||
imap_appended_count = 0
|
send=send,
|
||||||
imap_failed_count = 0
|
include_warnings=include_warnings,
|
||||||
|
include_needs_review=include_needs_review,
|
||||||
for built in build_result.built_messages:
|
append_sent=append_sent,
|
||||||
draft = built.draft
|
)
|
||||||
can_send, skip_reason = _can_mock_send(draft, include_warnings=include_warnings, include_needs_review=include_needs_review)
|
|
||||||
row: dict[str, Any] = {
|
|
||||||
"entry_index": draft.entry_index,
|
|
||||||
"entry_id": draft.entry_id,
|
|
||||||
"subject": draft.subject,
|
|
||||||
"validation_status": draft.validation_status.value,
|
|
||||||
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status),
|
|
||||||
"to": _message_addresses_payload(draft.to),
|
|
||||||
"attachments": _attachment_payloads(draft),
|
|
||||||
"issues": _issue_payloads(draft),
|
|
||||||
}
|
|
||||||
|
|
||||||
if not can_send or built.mime is None:
|
|
||||||
skipped_count += 1
|
|
||||||
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
|
|
||||||
send_results.append(row)
|
|
||||||
continue
|
|
||||||
|
|
||||||
recipients = _recipient_emails(draft)
|
|
||||||
envelope_from = _envelope_from(draft)
|
|
||||||
if not recipients:
|
|
||||||
skipped_count += 1
|
|
||||||
row.update({"status": "skipped", "message": "No envelope recipients"})
|
|
||||||
send_results.append(row)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not send:
|
|
||||||
row.update({"status": "ready", "message": f"Would send to {len(recipients)} recipient(s)", "envelope_from": envelope_from, "envelope_recipients": recipients})
|
|
||||||
send_results.append(row)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
if mailbox is not None and mailbox.consume_fail_next_smtp():
|
|
||||||
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
|
|
||||||
rejected = _smtp_rejection_matches(recipients)
|
|
||||||
if rejected and len(rejected) == len(recipients):
|
|
||||||
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
|
|
||||||
|
|
||||||
accepted = [recipient for recipient in recipients if recipient not in rejected]
|
|
||||||
smtp_record = mailbox.record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
|
|
||||||
sent_count += 1
|
|
||||||
row.update({
|
|
||||||
"status": "sent",
|
|
||||||
"message": f"Mock SMTP captured as {smtp_record.id}",
|
|
||||||
"smtp_message_id": smtp_record.id,
|
|
||||||
"envelope_from": envelope_from,
|
|
||||||
"envelope_recipients": accepted,
|
|
||||||
"refused_recipients": rejected,
|
|
||||||
})
|
|
||||||
|
|
||||||
if append_sent:
|
|
||||||
try:
|
|
||||||
if mailbox is not None and mailbox.consume_fail_next_imap():
|
|
||||||
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
|
|
||||||
folder = "Sent"
|
|
||||||
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
|
|
||||||
folder = config.delivery.imap_append_sent.folder
|
|
||||||
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
|
|
||||||
folder = config.server.imap.sent_folder
|
|
||||||
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
|
|
||||||
imap_appended_count += 1
|
|
||||||
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
|
|
||||||
except Exception as exc:
|
|
||||||
imap_failed_count += 1
|
|
||||||
row.update({"imap_status": "failed", "imap_error": str(exc)})
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
failed_count += 1
|
|
||||||
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
|
|
||||||
|
|
||||||
send_results.append(row)
|
|
||||||
|
|
||||||
validation_json = validation_report.model_dump(mode="json")
|
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})
|
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
|
||||||
@@ -261,7 +372,6 @@ def run_mock_campaign_send(
|
|||||||
"messages": [_message_payload(message) for message in build_report.messages],
|
"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 {
|
return {
|
||||||
"campaign_id": campaign.id,
|
"campaign_id": campaign.id,
|
||||||
"version_id": version.id,
|
"version_id": version.id,
|
||||||
@@ -273,19 +383,19 @@ def run_mock_campaign_send(
|
|||||||
"steps": [
|
"steps": [
|
||||||
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
|
{"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": "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": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if send_batch.failed_count == 0 else "needs_review"), "summary": {"attempted": send_batch.attempted_count, "sent": send_batch.sent_count, "failed": send_batch.failed_count, "skipped": send_batch.skipped_count}},
|
||||||
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if imap_failed_count == 0 else "needs_review"), "summary": {"appended": imap_appended_count, "failed": imap_failed_count}},
|
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if send_batch.imap_failed_count == 0 else "needs_review"), "summary": {"appended": send_batch.imap_appended_count, "failed": send_batch.imap_failed_count}},
|
||||||
],
|
],
|
||||||
"validation": validation_json,
|
"validation": validation_json,
|
||||||
"build": build_json,
|
"build": build_json,
|
||||||
"send": {
|
"send": {
|
||||||
"attempted_count": attempted_count,
|
"attempted_count": send_batch.attempted_count,
|
||||||
"sent_count": sent_count,
|
"sent_count": send_batch.sent_count,
|
||||||
"failed_count": failed_count,
|
"failed_count": send_batch.failed_count,
|
||||||
"skipped_count": skipped_count,
|
"skipped_count": send_batch.skipped_count,
|
||||||
"imap_appended_count": imap_appended_count,
|
"imap_appended_count": send_batch.imap_appended_count,
|
||||||
"imap_failed_count": imap_failed_count,
|
"imap_failed_count": send_batch.imap_failed_count,
|
||||||
"results": send_results,
|
"results": send_batch.results,
|
||||||
},
|
},
|
||||||
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
|
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class _PreparedCampaignSnapshot:
|
|||||||
self.raw_json = raw_json
|
self.raw_json = raw_json
|
||||||
self.managed_files_by_local_path: dict[str, Any] = {}
|
self.managed_files_by_local_path: dict[str, Any] = {}
|
||||||
self.shared_assets: list[Any] = []
|
self.shared_assets: list[Any] = []
|
||||||
|
self.candidate_assets: list[Any] = []
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
shutil.rmtree(self._directory, ignore_errors=True)
|
shutil.rmtree(self._directory, ignore_errors=True)
|
||||||
@@ -107,6 +108,11 @@ class FilesCampaignIntegration:
|
|||||||
raise OptionalModuleUnavailable("Files module is not available")
|
raise OptionalModuleUnavailable("Files module is not available")
|
||||||
return self._delegate.current_version_and_blob(session, asset)
|
return self._delegate.current_version_and_blob(session, asset)
|
||||||
|
|
||||||
|
def share_assets_with_campaign(self, session: Any, **kwargs: Any) -> list[dict[str, Any]]:
|
||||||
|
if self._delegate is None:
|
||||||
|
raise OptionalModuleUnavailable("Files module is not available")
|
||||||
|
return self._delegate.share_assets_with_campaign(session, **kwargs)
|
||||||
|
|
||||||
def mark_job_attachment_uses_sent(self, session: Any, job: Any) -> None:
|
def mark_job_attachment_uses_sent(self, session: Any, job: Any) -> None:
|
||||||
if self._delegate is not None:
|
if self._delegate is not None:
|
||||||
self._delegate.mark_job_attachment_uses_sent(session, job)
|
self._delegate.mark_job_attachment_uses_sent(session, job)
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ manifest = ModuleManifest(
|
|||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.8",
|
version="0.1.8",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("files", "mail"),
|
optional_dependencies=("files", "mail", "notifications", "addresses"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
||||||
@@ -165,6 +165,18 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="addresses.lookup",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="addresses.recipient_source",
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_campaigns_router,
|
route_factory=_campaigns_router,
|
||||||
@@ -175,7 +187,7 @@ manifest = ModuleManifest(
|
|||||||
NavItem(
|
NavItem(
|
||||||
path="/operator",
|
path="/operator",
|
||||||
label="Operator Queue",
|
label="Operator Queue",
|
||||||
icon="activity",
|
icon="radio-tower",
|
||||||
required_any=(
|
required_any=(
|
||||||
"campaigns:campaign:queue",
|
"campaigns:campaign:queue",
|
||||||
"campaigns:campaign:retry",
|
"campaigns:campaign:retry",
|
||||||
@@ -185,7 +197,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
order=30,
|
order=30,
|
||||||
),
|
),
|
||||||
NavItem(path="/reports", label="Reports", icon="reports", required_any=("campaigns:report:read",), order=70),
|
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
|
||||||
),
|
),
|
||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id="campaigns",
|
module_id="campaigns",
|
||||||
@@ -195,7 +207,7 @@ manifest = ModuleManifest(
|
|||||||
NavItem(
|
NavItem(
|
||||||
path="/operator",
|
path="/operator",
|
||||||
label="Operator Queue",
|
label="Operator Queue",
|
||||||
icon="activity",
|
icon="radio-tower",
|
||||||
required_any=(
|
required_any=(
|
||||||
"campaigns:campaign:queue",
|
"campaigns:campaign:queue",
|
||||||
"campaigns:campaign:retry",
|
"campaigns:campaign:retry",
|
||||||
@@ -205,9 +217,8 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
order=30,
|
order=30,
|
||||||
),
|
),
|
||||||
NavItem(path="/reports", label="Reports", icon="reports", required_any=("campaigns:report:read",), order=70),
|
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
|
||||||
NavItem(path="/address-book", label="Address Book", icon="users", order=80),
|
NavItem(path="/templates", label="Templates", icon="layout-template", order=90),
|
||||||
NavItem(path="/templates", label="Templates", icon="form", order=90),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
|
|||||||
@@ -81,6 +81,33 @@ class CampaignBuildResult:
|
|||||||
built_messages: list[BuiltMessage]
|
built_messages: list[BuiltMessage]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _EntryMessageContext:
|
||||||
|
resolution: EntryAttachmentResolution
|
||||||
|
senders: list[RecipientConfig]
|
||||||
|
sender: RecipientConfig | None
|
||||||
|
recipients: dict[str, list[RecipientConfig]]
|
||||||
|
issues: list[MessageIssue]
|
||||||
|
validation_status: MessageValidationStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _RenderedMessageTemplate:
|
||||||
|
subject: str
|
||||||
|
text_body: str | None
|
||||||
|
html_body: str | None
|
||||||
|
body_mode: str
|
||||||
|
values: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _MimeBuildResult:
|
||||||
|
message: EmailMessage | None
|
||||||
|
build_status: BuildStatus
|
||||||
|
validation_status: MessageValidationStatus
|
||||||
|
attachment_count: int
|
||||||
|
|
||||||
|
|
||||||
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
||||||
campaign_path = Path(campaign_file).resolve()
|
campaign_path = Path(campaign_file).resolve()
|
||||||
path = Path(raw_path).expanduser()
|
path = Path(raw_path).expanduser()
|
||||||
@@ -218,6 +245,26 @@ def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolu
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _append_no_attachment_coverage_issue(issues: list[MessageIssue]) -> None:
|
||||||
|
if any(issue.code == "missing_attachment_coverage" for issue in issues):
|
||||||
|
return
|
||||||
|
issues.append(
|
||||||
|
MessageIssue(
|
||||||
|
severity="error",
|
||||||
|
code="missing_attachment_coverage",
|
||||||
|
message="No attachment file was resolved for this message, and sending without attachments is not allowed.",
|
||||||
|
behavior=Behavior.BLOCK.value,
|
||||||
|
source="attachments",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
|
||||||
|
return config.attachments.send_without_attachments_behavior or (
|
||||||
|
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _safe_filename(value: str | None, fallback: str) -> str:
|
def _safe_filename(value: str | None, fallback: str) -> str:
|
||||||
raw = value or fallback
|
raw = value or fallback
|
||||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
|
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
|
||||||
@@ -364,6 +411,7 @@ def _attach_files(
|
|||||||
for attachment in resolution.attachments:
|
for attachment in resolution.attachments:
|
||||||
attachment.message_filenames = []
|
attachment.message_filenames = []
|
||||||
attachment.zip_entry_names = []
|
attachment.zip_entry_names = []
|
||||||
|
attachment.zip_filename = None
|
||||||
|
|
||||||
for attachment in resolution.attachments:
|
for attachment in resolution.attachments:
|
||||||
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
|
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
|
||||||
@@ -408,6 +456,7 @@ def _attach_files(
|
|||||||
work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename,
|
work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename,
|
||||||
members,
|
members,
|
||||||
password,
|
password,
|
||||||
|
archive.method.value,
|
||||||
)
|
)
|
||||||
data, maintype, subtype = _attachment_bytes(archive_path)
|
data, maintype, subtype = _attachment_bytes(archive_path)
|
||||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||||
@@ -431,81 +480,200 @@ def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entr
|
|||||||
return str(path), path.stat().st_size
|
return str(path), path.stat().st_size
|
||||||
|
|
||||||
|
|
||||||
def build_entry_message(
|
def _entry_message_context(
|
||||||
*,
|
*,
|
||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
campaign_file: str | Path,
|
campaign_file: str | Path,
|
||||||
entry: EntryConfig,
|
entry: EntryConfig,
|
||||||
entry_index: int,
|
entry_index: int,
|
||||||
output_dir: Path | None = None,
|
attachment_match_index: AttachmentMatchIndex | None,
|
||||||
write_eml: bool = False,
|
) -> _EntryMessageContext:
|
||||||
work_dir: Path | None = None,
|
resolution = resolve_entry_attachments(
|
||||||
attachment_match_index: AttachmentMatchIndex | None = None,
|
config=config,
|
||||||
) -> BuiltMessage:
|
campaign_file=campaign_file,
|
||||||
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index, match_index=attachment_match_index)
|
entry=entry,
|
||||||
|
entry_index=entry_index,
|
||||||
|
match_index=attachment_match_index,
|
||||||
|
)
|
||||||
effective_addresses = effective_address_lists(config, entry)
|
effective_addresses = effective_address_lists(config, entry)
|
||||||
senders = effective_addresses["from"]
|
senders = effective_addresses["from"]
|
||||||
sender = senders[0] if senders else None
|
sender = senders[0] if senders else None
|
||||||
recipients = {key: value for key, value in effective_addresses.items() if key != "from"}
|
recipients = {key: value for key, value in effective_addresses.items() if key != "from"}
|
||||||
issues = _message_issues_from_attachment_resolution(resolution)
|
issues = _message_issues_from_attachment_resolution(resolution)
|
||||||
validation_status = _validation_status_from_attachment_status(resolution.status)
|
validation_status = _validation_status_from_attachment_status(resolution.status)
|
||||||
|
validation_status = _apply_ignored_field_override_issues(config, entry, issues, validation_status)
|
||||||
|
return _EntryMessageContext(
|
||||||
|
resolution=resolution,
|
||||||
|
senders=senders,
|
||||||
|
sender=sender,
|
||||||
|
recipients=recipients,
|
||||||
|
issues=issues,
|
||||||
|
validation_status=validation_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_ignored_field_override_issues(
|
||||||
|
config: CampaignConfig,
|
||||||
|
entry: EntryConfig,
|
||||||
|
issues: list[MessageIssue],
|
||||||
|
validation_status: MessageValidationStatus,
|
||||||
|
) -> MessageValidationStatus:
|
||||||
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
|
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
|
||||||
if ignored_field_overrides:
|
if not ignored_field_overrides:
|
||||||
|
return validation_status
|
||||||
issues.append(
|
issues.append(
|
||||||
MessageIssue(
|
MessageIssue(
|
||||||
severity="warning",
|
severity="warning",
|
||||||
code="field_override_not_allowed",
|
code="field_override_not_allowed",
|
||||||
message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides),
|
message=(
|
||||||
|
"Recipient field value(s) ignored because the campaign field does not allow overrides: "
|
||||||
|
+ ", ".join(ignored_field_overrides)
|
||||||
|
),
|
||||||
behavior="warn",
|
behavior="warn",
|
||||||
source="fields",
|
source="fields",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if validation_status == MessageValidationStatus.READY:
|
if validation_status == MessageValidationStatus.READY:
|
||||||
validation_status = MessageValidationStatus.WARNING
|
return MessageValidationStatus.WARNING
|
||||||
|
return validation_status
|
||||||
|
|
||||||
if not entry.active:
|
|
||||||
draft = MessageDraft(
|
def _message_draft(
|
||||||
|
*,
|
||||||
|
config: CampaignConfig,
|
||||||
|
entry: EntryConfig,
|
||||||
|
entry_index: int,
|
||||||
|
context: _EntryMessageContext,
|
||||||
|
build_status: BuildStatus,
|
||||||
|
validation_status: MessageValidationStatus,
|
||||||
|
send_status: SendStatus = SendStatus.DRAFT,
|
||||||
|
imap_status: ImapStatus | None = None,
|
||||||
|
subject: str | None = None,
|
||||||
|
attachment_count: int = 0,
|
||||||
|
issues: list[MessageIssue] | None = None,
|
||||||
|
eml_path: str | None = None,
|
||||||
|
eml_size: int | None = None,
|
||||||
|
) -> MessageDraft:
|
||||||
|
if imap_status is None:
|
||||||
|
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
|
||||||
|
return MessageDraft(
|
||||||
entry_index=entry_index,
|
entry_index=entry_index,
|
||||||
entry_id=entry.id,
|
entry_id=entry.id,
|
||||||
active=False,
|
active=entry.active,
|
||||||
|
build_status=build_status,
|
||||||
|
validation_status=validation_status,
|
||||||
|
send_status=send_status,
|
||||||
|
imap_status=imap_status,
|
||||||
|
subject=subject,
|
||||||
|
from_=_message_address(context.sender),
|
||||||
|
from_all=_message_addresses(context.senders),
|
||||||
|
to=_message_addresses(context.recipients["to"]),
|
||||||
|
cc=_message_addresses(context.recipients["cc"]),
|
||||||
|
bcc=_message_addresses(context.recipients["bcc"]),
|
||||||
|
reply_to=_message_addresses(context.recipients["reply_to"]),
|
||||||
|
bounce_to=_message_addresses(context.recipients["bounce_to"]),
|
||||||
|
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
|
||||||
|
attachment_count=attachment_count,
|
||||||
|
attachments=_attachment_summaries(context.resolution),
|
||||||
|
issues=issues if issues is not None else context.issues,
|
||||||
|
eml_path=eml_path,
|
||||||
|
eml_size_bytes=eml_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inactive_entry_message(
|
||||||
|
*,
|
||||||
|
config: CampaignConfig,
|
||||||
|
entry: EntryConfig,
|
||||||
|
entry_index: int,
|
||||||
|
context: _EntryMessageContext,
|
||||||
|
) -> BuiltMessage:
|
||||||
|
draft = _message_draft(
|
||||||
|
config=config,
|
||||||
|
entry=entry,
|
||||||
|
entry_index=entry_index,
|
||||||
|
context=context,
|
||||||
build_status=BuildStatus.BUILD_FAILED,
|
build_status=BuildStatus.BUILD_FAILED,
|
||||||
validation_status=MessageValidationStatus.INACTIVE,
|
validation_status=MessageValidationStatus.INACTIVE,
|
||||||
send_status=SendStatus.DRAFT,
|
|
||||||
imap_status=ImapStatus.SKIPPED,
|
imap_status=ImapStatus.SKIPPED,
|
||||||
from_=_message_address(sender),
|
issues=[
|
||||||
from_all=_message_addresses(senders),
|
MessageIssue(
|
||||||
to=_message_addresses(recipients["to"]),
|
severity="info",
|
||||||
cc=_message_addresses(recipients["cc"]),
|
code="inactive_entry",
|
||||||
bcc=_message_addresses(recipients["bcc"]),
|
message="Entry is inactive",
|
||||||
reply_to=_message_addresses(recipients["reply_to"]),
|
behavior=config.validation_policy.inactive_entry.value,
|
||||||
bounce_to=_message_addresses(recipients["bounce_to"]),
|
source="entry",
|
||||||
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
|
)
|
||||||
attachments=_attachment_summaries(resolution),
|
],
|
||||||
issues=[MessageIssue(severity="info", code="inactive_entry", message="Entry is inactive", behavior=config.validation_policy.inactive_entry.value, source="entry")],
|
|
||||||
)
|
)
|
||||||
return BuiltMessage(draft=draft, mime=None)
|
return 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
|
|
||||||
|
|
||||||
|
def _validate_required_recipients(
|
||||||
|
config: CampaignConfig,
|
||||||
|
recipients: dict[str, list[RecipientConfig]],
|
||||||
|
issues: list[MessageIssue],
|
||||||
|
validation_status: MessageValidationStatus,
|
||||||
|
) -> MessageValidationStatus:
|
||||||
|
if recipients["to"]:
|
||||||
|
return validation_status
|
||||||
|
behavior = config.validation_policy.missing_email.value
|
||||||
|
issues.append(
|
||||||
|
_issue_from_behavior(
|
||||||
|
code="missing_email",
|
||||||
|
message="No effective To recipient is configured",
|
||||||
|
behavior=behavior,
|
||||||
|
source="recipients",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if behavior == MissingAddressBehavior.BLOCK.value:
|
||||||
|
return MessageValidationStatus.BLOCKED
|
||||||
|
return MessageValidationStatus.EXCLUDED
|
||||||
|
|
||||||
|
|
||||||
|
def _render_message_template(
|
||||||
|
config: CampaignConfig,
|
||||||
|
campaign_file: str | Path,
|
||||||
|
entry: EntryConfig,
|
||||||
|
) -> _RenderedMessageTemplate:
|
||||||
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
||||||
body_mode = _template_body_mode(config)
|
body_mode = _template_body_mode(config)
|
||||||
values = build_template_values(config, entry)
|
values = build_template_values(config, entry)
|
||||||
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
|
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
|
||||||
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
|
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
|
text_body = None
|
||||||
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None
|
html_body = None
|
||||||
|
if text_template is not None:
|
||||||
|
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders)
|
||||||
|
if html_template is not None:
|
||||||
|
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders)
|
||||||
|
return _RenderedMessageTemplate(
|
||||||
|
subject=subject,
|
||||||
|
text_body=text_body,
|
||||||
|
html_body=html_body,
|
||||||
|
body_mode=body_mode,
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
|
|
||||||
unresolved_fields = _find_unresolved_placeholders(subject)
|
|
||||||
if body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
|
def _unresolved_template_fields(rendered: _RenderedMessageTemplate) -> list[str]:
|
||||||
unresolved_fields |= _find_unresolved_placeholders(text_body)
|
unresolved_fields = _find_unresolved_placeholders(rendered.subject)
|
||||||
if body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
|
if rendered.body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
|
||||||
unresolved_fields |= _find_unresolved_placeholders(html_body)
|
unresolved_fields |= _find_unresolved_placeholders(rendered.text_body)
|
||||||
unresolved = sorted(unresolved_fields)
|
if rendered.body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
|
||||||
if unresolved:
|
unresolved_fields |= _find_unresolved_placeholders(rendered.html_body)
|
||||||
|
return sorted(unresolved_fields)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_rendered_template(
|
||||||
|
config: CampaignConfig,
|
||||||
|
rendered: _RenderedMessageTemplate,
|
||||||
|
issues: list[MessageIssue],
|
||||||
|
validation_status: MessageValidationStatus,
|
||||||
|
) -> MessageValidationStatus:
|
||||||
|
unresolved = _unresolved_template_fields(rendered)
|
||||||
|
if not unresolved:
|
||||||
|
return validation_status
|
||||||
behavior = config.validation_policy.template_error.value
|
behavior = config.validation_policy.template_error.value
|
||||||
issues.append(
|
issues.append(
|
||||||
_issue_from_behavior(
|
_issue_from_behavior(
|
||||||
@@ -515,10 +683,18 @@ def build_entry_message(
|
|||||||
source="template",
|
source="template",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
if behavior == MissingAddressBehavior.BLOCK.value:
|
||||||
|
return MessageValidationStatus.BLOCKED
|
||||||
|
return MessageValidationStatus.EXCLUDED
|
||||||
|
|
||||||
message = EmailMessage()
|
|
||||||
try:
|
def _populate_message_headers(
|
||||||
|
message: EmailMessage,
|
||||||
|
*,
|
||||||
|
senders: list[RecipientConfig],
|
||||||
|
recipients: dict[str, list[RecipientConfig]],
|
||||||
|
subject: str,
|
||||||
|
) -> None:
|
||||||
message["Date"] = formatdate(localtime=True)
|
message["Date"] = formatdate(localtime=True)
|
||||||
message["Message-ID"] = make_msgid()
|
message["Message-ID"] = make_msgid()
|
||||||
if senders:
|
if senders:
|
||||||
@@ -536,75 +712,160 @@ def build_entry_message(
|
|||||||
if recipients["reply_to"]:
|
if recipients["reply_to"]:
|
||||||
message["Reply-To"] = _format_recipient_header(recipients["reply_to"])
|
message["Reply-To"] = _format_recipient_header(recipients["reply_to"])
|
||||||
if recipients["disposition_notification_to"]:
|
if recipients["disposition_notification_to"]:
|
||||||
message["Disposition-Notification-To"] = _format_recipient_header(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.
|
# bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender.
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
|
|
||||||
effective_html_body = html_body if html_body and html_body.strip() else None
|
|
||||||
if body_mode == TemplateBodyMode.HTML.value:
|
def _populate_message_body(message: EmailMessage, rendered: _RenderedMessageTemplate) -> None:
|
||||||
|
effective_html_body = rendered.html_body if rendered.html_body and rendered.html_body.strip() else None
|
||||||
|
if rendered.body_mode == TemplateBodyMode.HTML.value:
|
||||||
message.set_content(effective_html_body or "", subtype="html")
|
message.set_content(effective_html_body or "", subtype="html")
|
||||||
elif body_mode == TemplateBodyMode.TEXT.value:
|
elif rendered.body_mode == TemplateBodyMode.TEXT.value:
|
||||||
message.set_content(text_body or "")
|
message.set_content(rendered.text_body or "")
|
||||||
elif effective_html_body is not None:
|
elif effective_html_body is not None:
|
||||||
message.set_content(text_body or "")
|
message.set_content(rendered.text_body or "")
|
||||||
message.add_alternative(effective_html_body, subtype="html")
|
message.add_alternative(effective_html_body, subtype="html")
|
||||||
else:
|
else:
|
||||||
message.set_content(text_body or "")
|
message.set_content(rendered.text_body or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mime_message(
|
||||||
|
*,
|
||||||
|
config: CampaignConfig,
|
||||||
|
entry: EntryConfig,
|
||||||
|
entry_index: int,
|
||||||
|
output_dir: Path | None,
|
||||||
|
work_dir: Path | None,
|
||||||
|
context: _EntryMessageContext,
|
||||||
|
rendered: _RenderedMessageTemplate,
|
||||||
|
) -> _MimeBuildResult:
|
||||||
|
message = EmailMessage()
|
||||||
|
try:
|
||||||
|
_populate_message_headers(
|
||||||
|
message,
|
||||||
|
senders=context.senders,
|
||||||
|
recipients=context.recipients,
|
||||||
|
subject=rendered.subject,
|
||||||
|
)
|
||||||
|
_populate_message_body(message, rendered)
|
||||||
if work_dir is None:
|
if work_dir is None:
|
||||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-"))
|
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
|
||||||
attachment_count = _attach_files(
|
attachment_count = _attach_files(
|
||||||
message=message,
|
message=message,
|
||||||
config=config,
|
config=config,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
entry_index=entry_index,
|
entry_index=entry_index,
|
||||||
resolution=resolution,
|
resolution=context.resolution,
|
||||||
values=values,
|
values=rendered.values,
|
||||||
work_dir=work_dir,
|
work_dir=work_dir,
|
||||||
)
|
)
|
||||||
build_status = BuildStatus.BUILT
|
if attachment_count == 0 and context.resolution.attachments and _send_without_attachments_behavior(config) == Behavior.BLOCK:
|
||||||
|
_append_no_attachment_coverage_issue(context.issues)
|
||||||
|
return _MimeBuildResult(
|
||||||
|
message=None,
|
||||||
|
build_status=BuildStatus.BUILD_FAILED,
|
||||||
|
validation_status=MessageValidationStatus.BLOCKED,
|
||||||
|
attachment_count=0,
|
||||||
|
)
|
||||||
|
return _MimeBuildResult(
|
||||||
|
message=message,
|
||||||
|
build_status=BuildStatus.BUILT,
|
||||||
|
validation_status=context.validation_status,
|
||||||
|
attachment_count=attachment_count,
|
||||||
|
)
|
||||||
except ZipBuildError as exc:
|
except ZipBuildError as exc:
|
||||||
issues.append(MessageIssue(severity="error", code=exc.code, message=str(exc), behavior="block", source="attachments"))
|
context.issues.append(
|
||||||
validation_status = MessageValidationStatus.BLOCKED
|
MessageIssue(
|
||||||
build_status = BuildStatus.BUILD_FAILED
|
severity="error",
|
||||||
attachment_count = 0
|
code=exc.code,
|
||||||
message = None # type: ignore[assignment]
|
message=str(exc),
|
||||||
|
behavior="block",
|
||||||
|
source="attachments",
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder"))
|
context.issues.append(
|
||||||
validation_status = MessageValidationStatus.BLOCKED
|
MessageIssue(
|
||||||
build_status = BuildStatus.BUILD_FAILED
|
severity="error",
|
||||||
attachment_count = 0
|
code="build_failed",
|
||||||
message = None # type: ignore[assignment]
|
message=str(exc),
|
||||||
|
behavior="block",
|
||||||
|
source="builder",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return _MimeBuildResult(
|
||||||
|
message=None,
|
||||||
|
build_status=BuildStatus.BUILD_FAILED,
|
||||||
|
validation_status=MessageValidationStatus.BLOCKED,
|
||||||
|
attachment_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_entry_message(
|
||||||
|
*,
|
||||||
|
config: CampaignConfig,
|
||||||
|
campaign_file: str | Path,
|
||||||
|
entry: EntryConfig,
|
||||||
|
entry_index: int,
|
||||||
|
output_dir: Path | None = None,
|
||||||
|
write_eml: bool = False,
|
||||||
|
work_dir: Path | None = None,
|
||||||
|
attachment_match_index: AttachmentMatchIndex | None = None,
|
||||||
|
) -> BuiltMessage:
|
||||||
|
context = _entry_message_context(
|
||||||
|
config=config,
|
||||||
|
campaign_file=campaign_file,
|
||||||
|
entry=entry,
|
||||||
|
entry_index=entry_index,
|
||||||
|
attachment_match_index=attachment_match_index,
|
||||||
|
)
|
||||||
|
if not entry.active:
|
||||||
|
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
|
||||||
|
|
||||||
|
context.validation_status = _validate_required_recipients(
|
||||||
|
config,
|
||||||
|
context.recipients,
|
||||||
|
context.issues,
|
||||||
|
context.validation_status,
|
||||||
|
)
|
||||||
|
rendered = _render_message_template(config, campaign_file, entry)
|
||||||
|
context.validation_status = _validate_rendered_template(
|
||||||
|
config,
|
||||||
|
rendered,
|
||||||
|
context.issues,
|
||||||
|
context.validation_status,
|
||||||
|
)
|
||||||
|
mime_result = _build_mime_message(
|
||||||
|
config=config,
|
||||||
|
entry=entry,
|
||||||
|
entry_index=entry_index,
|
||||||
|
output_dir=output_dir,
|
||||||
|
work_dir=work_dir,
|
||||||
|
context=context,
|
||||||
|
rendered=rendered,
|
||||||
|
)
|
||||||
|
|
||||||
eml_path: str | None = None
|
eml_path: str | None = None
|
||||||
eml_size: int | None = None
|
eml_size: int | None = None
|
||||||
if write_eml and output_dir is not None and message is not None:
|
if write_eml and output_dir is not None and mime_result.message is not None:
|
||||||
eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index)
|
eml_path, eml_size = _write_eml(mime_result.message, output_dir, entry, entry_index)
|
||||||
|
|
||||||
draft = MessageDraft(
|
draft = _message_draft(
|
||||||
|
config=config,
|
||||||
|
entry=entry,
|
||||||
entry_index=entry_index,
|
entry_index=entry_index,
|
||||||
entry_id=entry.id,
|
context=context,
|
||||||
active=entry.active,
|
build_status=mime_result.build_status,
|
||||||
build_status=build_status,
|
validation_status=mime_result.validation_status,
|
||||||
validation_status=validation_status,
|
subject=rendered.subject,
|
||||||
send_status=SendStatus.DRAFT,
|
attachment_count=mime_result.attachment_count,
|
||||||
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_path=eml_path,
|
||||||
eml_size_bytes=eml_size,
|
eml_size=eml_size,
|
||||||
)
|
)
|
||||||
return BuiltMessage(draft=draft, mime=message)
|
return BuiltMessage(draft=draft, mime=mime_result.message)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -680,7 +941,7 @@ def build_campaign_messages(
|
|||||||
|
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
attachment_match_index = AttachmentMatchIndex()
|
attachment_match_index = AttachmentMatchIndex()
|
||||||
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
|
with tempfile.TemporaryDirectory(prefix="govoplan-build-") as tmp:
|
||||||
work_dir = output_path or Path(tmp)
|
work_dir = output_path or Path(tmp)
|
||||||
built_messages = [
|
built_messages = [
|
||||||
build_entry_message(
|
build_entry_message(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobSendStatus,
|
JobSendStatus,
|
||||||
JobValidationStatus,
|
JobValidationStatus,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json, validate_against_schema
|
from govoplan_campaign.backend.campaign.loader import load_campaign_json, validate_against_schema
|
||||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
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.builder import build_campaign_messages
|
||||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||||
@@ -275,7 +275,7 @@ def validate_campaign_version(
|
|||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||||
include_bytes=False,
|
include_bytes=False,
|
||||||
prefix="multimailer-managed-validate-",
|
prefix="govoplan-managed-validate-",
|
||||||
) as prepared:
|
) as prepared:
|
||||||
managed_raw = load_campaign_json(prepared.path)
|
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)
|
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
|
||||||
@@ -407,7 +407,7 @@ def build_campaign_version(
|
|||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||||
include_bytes=True,
|
include_bytes=True,
|
||||||
prefix="multimailer-managed-build-",
|
prefix="govoplan-managed-build-",
|
||||||
) as prepared:
|
) as prepared:
|
||||||
managed_raw = load_campaign_json(prepared.path)
|
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)
|
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -31,6 +32,39 @@ class LockedCampaignVersionError(CampaignPersistenceError):
|
|||||||
"""Raised when a caller tries to edit an immutable campaign version."""
|
"""Raised when a caller tries to edit an immutable campaign version."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _PartialValidationCollector:
|
||||||
|
section: str | None
|
||||||
|
issues: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def issue(self, severity: str, section: str, field: str, code: str, message: str) -> None:
|
||||||
|
if self.section is None or self.section == section:
|
||||||
|
self.issues.append({
|
||||||
|
"severity": severity,
|
||||||
|
"section": section,
|
||||||
|
"field": field,
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
def result(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": not any(item["severity"] == "error" for item in self.issues),
|
||||||
|
"section": self.section,
|
||||||
|
"error_count": sum(1 for item in self.issues if item["severity"] == "error"),
|
||||||
|
"warning_count": sum(1 for item in self.issues if item["severity"] == "warning"),
|
||||||
|
"info_count": sum(1 for item in self.issues if item["severity"] == "info"),
|
||||||
|
"issues": self.issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_campaign(session: Session, campaign_id: str) -> Campaign:
|
||||||
|
campaign = session.get(Campaign, campaign_id)
|
||||||
|
if campaign is None:
|
||||||
|
raise CampaignPersistenceError(f"Campaign not found: {campaign_id}")
|
||||||
|
return campaign
|
||||||
|
|
||||||
|
|
||||||
USER_LOCK_TEMPORARY = "temporary"
|
USER_LOCK_TEMPORARY = "temporary"
|
||||||
USER_LOCK_PERMANENT = "permanent"
|
USER_LOCK_PERMANENT = "permanent"
|
||||||
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
|
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
|
||||||
@@ -124,6 +158,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
|||||||
],
|
],
|
||||||
"allow_individual": True,
|
"allow_individual": True,
|
||||||
"send_without_attachments": False,
|
"send_without_attachments": False,
|
||||||
|
"send_without_attachments_behavior": "block",
|
||||||
"global": [],
|
"global": [],
|
||||||
"zip": {"enabled": False, "archives": []},
|
"zip": {"enabled": False, "archives": []},
|
||||||
"missing_behavior": "ask",
|
"missing_behavior": "ask",
|
||||||
@@ -320,8 +355,7 @@ def fork_campaign_version_for_edit(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
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)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
assert campaign is not None
|
|
||||||
|
|
||||||
if campaign_has_active_working_version(session, campaign):
|
if campaign_has_active_working_version(session, campaign):
|
||||||
current = session.get(CampaignVersion, campaign.current_version_id)
|
current = session.get(CampaignVersion, campaign.current_version_id)
|
||||||
@@ -429,8 +463,7 @@ def unlock_validated_campaign_version(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
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)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
assert campaign is not None
|
|
||||||
ensure_current_working_version(campaign, version, action="unlock")
|
ensure_current_working_version(campaign, version, action="unlock")
|
||||||
|
|
||||||
if is_temporary_user_locked_version(version):
|
if is_temporary_user_locked_version(version):
|
||||||
@@ -490,8 +523,7 @@ def update_campaign_version(
|
|||||||
autosave: bool = False,
|
autosave: bool = False,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
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)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
assert campaign is not None
|
|
||||||
ensure_current_working_version(campaign, version, action="edit")
|
ensure_current_working_version(campaign, version, action="edit")
|
||||||
|
|
||||||
if is_version_locked(version):
|
if is_version_locked(version):
|
||||||
@@ -566,25 +598,45 @@ def update_campaign_review_state(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
)
|
)
|
||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
assert campaign is not None
|
|
||||||
ensure_current_working_version(campaign, version, action="record review state for")
|
ensure_current_working_version(campaign, version, action="record review state for")
|
||||||
if is_version_final_locked(version):
|
if is_version_final_locked(version):
|
||||||
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
|
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
|
||||||
|
build_token = _campaign_review_build_token(version)
|
||||||
|
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
|
||||||
|
if inspection_complete:
|
||||||
|
normalized_reviewed = _complete_campaign_review_keys(session, version, normalized_reviewed)
|
||||||
|
_write_campaign_review_state(
|
||||||
|
version,
|
||||||
|
build_token=build_token,
|
||||||
|
inspection_complete=inspection_complete,
|
||||||
|
reviewed_message_keys=normalized_reviewed,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_review_build_token(version: CampaignVersion) -> str:
|
||||||
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
|
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||||
if not build_summary:
|
if not build_summary:
|
||||||
raise CampaignPersistenceError("Build messages before recording review state.")
|
raise CampaignPersistenceError("Build messages before recording review state.")
|
||||||
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
|
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
|
||||||
if not build_token:
|
if build_token:
|
||||||
# Backwards-compatible upgrade for build summaries created before
|
return build_token
|
||||||
# review-state tokens were introduced.
|
|
||||||
build_token = uuid4().hex
|
build_token = uuid4().hex
|
||||||
build_summary = copy.deepcopy(build_summary)
|
updated_summary = copy.deepcopy(build_summary)
|
||||||
build_summary["build_token"] = build_token
|
updated_summary["build_token"] = build_token
|
||||||
version.build_summary = build_summary
|
version.build_summary = updated_summary
|
||||||
|
return build_token
|
||||||
|
|
||||||
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
|
|
||||||
if inspection_complete:
|
def _complete_campaign_review_keys(
|
||||||
|
session: Session,
|
||||||
|
version: CampaignVersion,
|
||||||
|
reviewed_message_keys: list[str],
|
||||||
|
) -> list[str]:
|
||||||
jobs = (
|
jobs = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
.filter(CampaignJob.campaign_version_id == version.id)
|
.filter(CampaignJob.campaign_version_id == version.id)
|
||||||
@@ -594,36 +646,47 @@ def update_campaign_review_state(
|
|||||||
blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"]
|
blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"]
|
||||||
if blocking:
|
if blocking:
|
||||||
raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.")
|
raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.")
|
||||||
reviewed = set(normalized_reviewed)
|
missing = sorted(_required_review_keys(jobs) - set(reviewed_message_keys))
|
||||||
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:
|
if missing:
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
|
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
|
||||||
)
|
)
|
||||||
bulk_acceptable = [
|
return list(dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]))
|
||||||
|
|
||||||
|
|
||||||
|
def _required_review_keys(jobs: list[CampaignJob]) -> set[str]:
|
||||||
|
return {
|
||||||
|
str(job.entry_id or job.entry_index)
|
||||||
|
for job in jobs
|
||||||
|
if job.validation_status == "needs_review"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_acceptable_review_keys(jobs: list[CampaignJob]) -> list[str]:
|
||||||
|
return [
|
||||||
str(job.entry_id or job.entry_index)
|
str(job.entry_id or job.entry_index)
|
||||||
for job in jobs
|
for job in jobs
|
||||||
if job.validation_status in {"warning", "excluded"}
|
if job.validation_status in {"warning", "excluded"}
|
||||||
]
|
]
|
||||||
normalized_reviewed = list(dict.fromkeys([*normalized_reviewed, *bulk_acceptable]))
|
|
||||||
|
|
||||||
|
|
||||||
|
def _write_campaign_review_state(
|
||||||
|
version: CampaignVersion,
|
||||||
|
*,
|
||||||
|
build_token: str,
|
||||||
|
inspection_complete: bool,
|
||||||
|
reviewed_message_keys: list[str],
|
||||||
|
user_id: str | None,
|
||||||
|
) -> None:
|
||||||
editor_state = copy.deepcopy(version.editor_state or {})
|
editor_state = copy.deepcopy(version.editor_state or {})
|
||||||
editor_state["review_send"] = {
|
editor_state["review_send"] = {
|
||||||
"build_token": build_token,
|
"build_token": build_token,
|
||||||
"inspection_complete": bool(inspection_complete),
|
"inspection_complete": bool(inspection_complete),
|
||||||
"reviewed_message_keys": normalized_reviewed,
|
"reviewed_message_keys": reviewed_message_keys,
|
||||||
"updated_at": datetime.now(UTC).isoformat(),
|
"updated_at": datetime.now(UTC).isoformat(),
|
||||||
"updated_by_user_id": user_id,
|
"updated_by_user_id": user_id,
|
||||||
}
|
}
|
||||||
version.editor_state = editor_state
|
version.editor_state = editor_state
|
||||||
session.add(version)
|
|
||||||
session.commit()
|
|
||||||
return version
|
|
||||||
|
|
||||||
|
|
||||||
def lock_campaign_version_temporarily(
|
def lock_campaign_version_temporarily(
|
||||||
@@ -642,8 +705,7 @@ def lock_campaign_version_temporarily(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
)
|
)
|
||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
assert campaign is not None
|
|
||||||
ensure_current_working_version(campaign, version, action="lock")
|
ensure_current_working_version(campaign, version, action="lock")
|
||||||
if is_version_final_locked(version):
|
if is_version_final_locked(version):
|
||||||
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
|
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
|
||||||
@@ -677,8 +739,7 @@ def unlock_user_locked_campaign_version(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
)
|
)
|
||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
assert campaign is not None
|
|
||||||
ensure_current_working_version(campaign, version, action="unlock")
|
ensure_current_working_version(campaign, version, action="unlock")
|
||||||
state = campaign_version_user_lock_state(version)
|
state = campaign_version_user_lock_state(version)
|
||||||
if state == USER_LOCK_PERMANENT:
|
if state == USER_LOCK_PERMANENT:
|
||||||
@@ -716,8 +777,7 @@ def permanently_lock_campaign_version(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
)
|
)
|
||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = _require_campaign(session, campaign_id)
|
||||||
assert campaign is not None
|
|
||||||
ensure_current_working_version(campaign, version, action="lock permanently")
|
ensure_current_working_version(campaign, version, action="lock permanently")
|
||||||
if is_version_final_locked(version):
|
if is_version_final_locked(version):
|
||||||
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
|
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
|
||||||
@@ -761,79 +821,101 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
|
|||||||
lets the WebUI autosave and validate one wizard step at a time.
|
lets the WebUI autosave and validate one wizard step at a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
issues: list[dict[str, Any]] = []
|
collector = _PartialValidationCollector(section=section)
|
||||||
|
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
|
||||||
|
recipients = _dict_value(raw_json, "recipients")
|
||||||
|
_validate_partial_sender(collector, recipients)
|
||||||
|
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
|
||||||
|
_validate_partial_template(collector, _dict_value(raw_json, "template"))
|
||||||
|
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
|
||||||
|
_validate_partial_delivery(collector, _dict_value(raw_json, "delivery"))
|
||||||
|
return collector.result()
|
||||||
|
|
||||||
def issue(severity: str, sec: str, field: str, code: str, message: str) -> None:
|
|
||||||
if section is None or section == sec:
|
|
||||||
issues.append({
|
|
||||||
"severity": severity,
|
|
||||||
"section": sec,
|
|
||||||
"field": field,
|
|
||||||
"code": code,
|
|
||||||
"message": message,
|
|
||||||
})
|
|
||||||
|
|
||||||
campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
def _dict_value(value: dict[str, Any], key: str) -> dict[str, Any]:
|
||||||
|
candidate = value.get(key)
|
||||||
|
return candidate if isinstance(candidate, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_partial_basics(collector: _PartialValidationCollector, campaign: dict[str, Any]) -> None:
|
||||||
if not campaign.get("id"):
|
if not campaign.get("id"):
|
||||||
issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
|
collector.issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
|
||||||
if not campaign.get("name"):
|
if not campaign.get("name"):
|
||||||
issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
|
collector.issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
|
||||||
|
|
||||||
recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {}
|
|
||||||
|
def _validate_partial_sender(collector: _PartialValidationCollector, recipients: dict[str, Any]) -> None:
|
||||||
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
|
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
|
||||||
if not sender.get("email"):
|
if not sender.get("email"):
|
||||||
issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
||||||
|
|
||||||
entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {}
|
|
||||||
|
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
|
||||||
has_inline = bool(entries.get("inline"))
|
has_inline = bool(entries.get("inline"))
|
||||||
has_source = isinstance(entries.get("source"), dict)
|
has_source = isinstance(entries.get("source"), dict)
|
||||||
if not has_inline and not has_source:
|
if not has_inline and not has_source:
|
||||||
issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
|
collector.issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
|
||||||
if has_source:
|
if has_source and not _entries_source_has_email_mapping(entries):
|
||||||
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
|
collector.issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
|
||||||
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 {}
|
|
||||||
|
def _entries_source_has_email_mapping(entries: dict[str, Any]) -> bool:
|
||||||
|
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
|
||||||
|
return any(key in mapping for key in ("to.0.email", "to.email", "email"))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_partial_template(collector: _PartialValidationCollector, template: dict[str, Any]) -> None:
|
||||||
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
|
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
|
||||||
if not template.get("subject") and not source_template.get("subject_path"):
|
if not template.get("subject") and not source_template.get("subject_path"):
|
||||||
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
|
collector.issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
|
||||||
explicit_body_mode = template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None
|
body_state = _partial_template_body_state(template, source_template)
|
||||||
has_text_body = bool(template.get("text")) or bool(source_template.get("text_path"))
|
_validate_partial_template_body(collector, body_state)
|
||||||
has_html_body = bool(template.get("html")) or bool(source_template.get("html_path"))
|
|
||||||
if explicit_body_mode == "text" and not has_text_body:
|
|
||||||
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
|
|
||||||
elif explicit_body_mode == "html" and not has_html_body:
|
|
||||||
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
|
|
||||||
elif explicit_body_mode == "both":
|
|
||||||
if not has_text_body:
|
|
||||||
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
|
|
||||||
if not has_html_body:
|
|
||||||
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
|
|
||||||
elif not has_text_body and not has_html_body and not source_template:
|
|
||||||
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
|
|
||||||
|
|
||||||
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
|
|
||||||
|
def _partial_template_body_state(template: dict[str, Any], source_template: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"mode": template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None,
|
||||||
|
"has_text": bool(template.get("text")) or bool(source_template.get("text_path")),
|
||||||
|
"has_html": bool(template.get("html")) or bool(source_template.get("html_path")),
|
||||||
|
"has_source": bool(source_template),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_partial_template_body(collector: _PartialValidationCollector, state: dict[str, Any]) -> None:
|
||||||
|
mode = state["mode"]
|
||||||
|
has_text = bool(state["has_text"])
|
||||||
|
has_html = bool(state["has_html"])
|
||||||
|
if mode == "text" and not has_text:
|
||||||
|
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
|
||||||
|
elif mode == "html" and not has_html:
|
||||||
|
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
|
||||||
|
elif mode == "both":
|
||||||
|
_validate_partial_dual_body_template(collector, has_text=has_text, has_html=has_html)
|
||||||
|
elif not has_text and not has_html and not state["has_source"]:
|
||||||
|
collector.issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_partial_dual_body_template(collector: _PartialValidationCollector, *, has_text: bool, has_html: bool) -> None:
|
||||||
|
if not has_text:
|
||||||
|
collector.issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
|
||||||
|
if not has_html:
|
||||||
|
collector.issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_partial_attachments(collector: _PartialValidationCollector, attachments: dict[str, Any]) -> None:
|
||||||
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
|
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)
|
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"):
|
if not has_named_base_path and not attachments.get("base_path"):
|
||||||
issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
|
collector.issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
|
||||||
|
|
||||||
delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {}
|
|
||||||
|
def _validate_partial_delivery(collector: _PartialValidationCollector, delivery: dict[str, Any]) -> None:
|
||||||
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
|
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
|
||||||
messages_per_minute = rate_limit.get("messages_per_minute")
|
messages_per_minute = rate_limit.get("messages_per_minute")
|
||||||
if messages_per_minute is not None:
|
if messages_per_minute is None:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
if int(messages_per_minute) < 1:
|
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.")
|
collector.issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
|
collector.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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -367,38 +367,127 @@ def generate_campaign_report(
|
|||||||
|
|
||||||
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||||
version = _selected_version(session, campaign, version_id)
|
version = _selected_version(session, campaign, version_id)
|
||||||
|
jobs = _report_jobs(session, tenant_id=tenant_id, campaign_id=campaign.id, version=version)
|
||||||
|
report = _campaign_report_payload(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
jobs=jobs,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
include_recent_failures=include_recent_failures,
|
||||||
|
)
|
||||||
|
if include_jobs:
|
||||||
|
report["jobs"] = [_job_row(job) for job in jobs]
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _report_jobs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version: CampaignVersion | None,
|
||||||
|
) -> list[CampaignJob]:
|
||||||
jobs_query = session.query(CampaignJob).filter(
|
jobs_query = session.query(CampaignJob).filter(
|
||||||
CampaignJob.tenant_id == tenant_id,
|
CampaignJob.tenant_id == tenant_id,
|
||||||
CampaignJob.campaign_id == campaign.id,
|
CampaignJob.campaign_id == campaign_id,
|
||||||
)
|
)
|
||||||
if version:
|
if version:
|
||||||
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
|
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
|
||||||
else:
|
else:
|
||||||
jobs_query = jobs_query.filter(False)
|
jobs_query = jobs_query.filter(False)
|
||||||
jobs = (
|
return jobs_query.order_by(CampaignJob.entry_index.asc()).all()
|
||||||
jobs_query
|
|
||||||
.order_by(CampaignJob.entry_index.asc())
|
|
||||||
.all()
|
def _campaign_report_payload(
|
||||||
)
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
version: CampaignVersion | None,
|
||||||
|
jobs: list[CampaignJob],
|
||||||
|
tenant_id: str,
|
||||||
|
include_recent_failures: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
job_ids = [job.id for job in jobs]
|
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
|
report = {
|
||||||
imap_attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
|
"generated_at": _utcnow_iso(),
|
||||||
|
"campaign": _campaign_report_campaign_payload(campaign),
|
||||||
|
"current_version": _version_info(version),
|
||||||
|
"selected_version_id": version.id if version else None,
|
||||||
|
"cards": _campaign_report_cards(version, jobs),
|
||||||
|
"status_counts": _campaign_report_status_counts(version, jobs),
|
||||||
|
"issues": {
|
||||||
|
**_issue_summary_from_jobs(jobs),
|
||||||
|
"persisted_campaign_issue_count": _persisted_campaign_issue_count(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version=version,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"attachments": _attachment_summary(jobs),
|
||||||
|
"attempts": _campaign_report_attempt_counts(session, job_ids),
|
||||||
|
"delivery": _load_delivery_info(version, jobs),
|
||||||
|
}
|
||||||
|
if include_recent_failures:
|
||||||
|
report["recent_failures"] = _recent_failures(jobs)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": campaign.id,
|
||||||
|
"external_id": campaign.external_id,
|
||||||
|
"name": campaign.name,
|
||||||
|
"description": campaign.description,
|
||||||
|
"status": campaign.status,
|
||||||
|
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||||
|
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_report_attempt_counts(session: Session, job_ids: list[str]) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"send_attempts": int(session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
|
||||||
|
"imap_append_attempts": int(session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _persisted_campaign_issue_count(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version: CampaignVersion | None,
|
||||||
|
) -> int:
|
||||||
issue_query = session.query(CampaignIssue).filter(
|
issue_query = session.query(CampaignIssue).filter(
|
||||||
CampaignIssue.tenant_id == tenant_id,
|
CampaignIssue.tenant_id == tenant_id,
|
||||||
CampaignIssue.campaign_id == campaign.id,
|
CampaignIssue.campaign_id == campaign_id,
|
||||||
)
|
)
|
||||||
if version:
|
if version:
|
||||||
issue_query = issue_query.filter(CampaignIssue.campaign_version_id == version.id)
|
issue_query = issue_query.filter(CampaignIssue.campaign_version_id == version.id)
|
||||||
else:
|
else:
|
||||||
issue_query = issue_query.filter(False)
|
issue_query = issue_query.filter(False)
|
||||||
persisted_issues = issue_query.count()
|
return int(issue_query.count())
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_report_status_counts(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, dict[str, int]]:
|
||||||
validation_counts = _counter([job.validation_status for job in jobs])
|
validation_counts = _counter([job.validation_status for job in jobs])
|
||||||
queue_counts = _counter([job.queue_status for job in jobs])
|
inactive_entries = _inactive_entry_count(version)
|
||||||
|
if inactive_entries:
|
||||||
|
validation_counts["inactive"] = inactive_entries
|
||||||
|
return {
|
||||||
|
"build": _counter([job.build_status for job in jobs]),
|
||||||
|
"validation": validation_counts,
|
||||||
|
"queue": _counter([job.queue_status for job in jobs]),
|
||||||
|
"send": _counter([job.send_status for job in jobs]),
|
||||||
|
"imap": _counter([job.imap_status for job in jobs]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
|
||||||
send_counts = _counter([job.send_status for job in jobs])
|
send_counts = _counter([job.send_status for job in jobs])
|
||||||
imap_counts = _counter([job.imap_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")
|
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
|
||||||
needs_attention = sum(
|
needs_attention = sum(
|
||||||
1
|
1
|
||||||
@@ -413,25 +502,8 @@ def generate_campaign_report(
|
|||||||
not_attempted = send_counts.get("not_queued", 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)
|
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0)
|
||||||
cancelled = send_counts.get("cancelled", 0)
|
cancelled = send_counts.get("cancelled", 0)
|
||||||
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
|
inactive_entries = _inactive_entry_count(version)
|
||||||
inactive_entries = int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
|
return {
|
||||||
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),
|
"jobs_total": len(jobs),
|
||||||
"inactive": inactive_entries,
|
"inactive": inactive_entries,
|
||||||
"queueable": queueable,
|
"queueable": queueable,
|
||||||
@@ -446,30 +518,12 @@ def generate_campaign_report(
|
|||||||
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
|
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
|
||||||
"imap_appended": imap_counts.get("appended", 0),
|
"imap_appended": imap_counts.get("appended", 0),
|
||||||
"imap_failed": imap_counts.get("failed", 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:
|
def _inactive_entry_count(version: CampaignVersion | None) -> int:
|
||||||
report["jobs"] = [_job_row(job) for job in jobs]
|
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
|
||||||
return report
|
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
|
||||||
|
|
||||||
|
|
||||||
def generate_jobs_csv(
|
def generate_jobs_csv(
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ def _text_summary(report: dict[str, Any]) -> str:
|
|||||||
]
|
]
|
||||||
if delivery.get("estimated_remaining_send_human"):
|
if delivery.get("estimated_remaining_send_human"):
|
||||||
lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"])
|
lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"])
|
||||||
lines.extend(["", "This report was generated by MultiMailer."])
|
lines.extend(["", "This report was generated by GovOPlaN."])
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -119,20 +119,20 @@ def build_report_message(
|
|||||||
report_json: dict[str, Any] | None = None,
|
report_json: dict[str, Any] | None = None,
|
||||||
) -> EmailMessage:
|
) -> EmailMessage:
|
||||||
from_email, from_name = _effective_from(config)
|
from_email, from_name = _effective_from(config)
|
||||||
subject = f"MultiMailer report: {campaign.name}"
|
subject = f"GovOPlaN report: {campaign.name}"
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg["Subject"] = subject
|
msg["Subject"] = subject
|
||||||
msg["From"] = formataddr((from_name or from_email, from_email))
|
msg["From"] = formataddr((from_name or from_email, from_email))
|
||||||
msg["To"] = ", ".join(to)
|
msg["To"] = ", ".join(to)
|
||||||
msg["X-MultiMailer-Report"] = "campaign"
|
msg["X-GovOPlaN-Report"] = "campaign"
|
||||||
msg.set_content(_text_summary(report))
|
msg.set_content(_text_summary(report))
|
||||||
|
|
||||||
if jobs_csv is not None:
|
if jobs_csv is not None:
|
||||||
filename = f"multimailer-{campaign.external_id}-jobs.csv"
|
filename = f"govoplan-{campaign.external_id}-jobs.csv"
|
||||||
msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename)
|
msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename)
|
||||||
if report_json is not None:
|
if report_json is not None:
|
||||||
filename = f"multimailer-{campaign.external_id}-report.json"
|
filename = f"govoplan-{campaign.external_id}-report.json"
|
||||||
msg.add_attachment(
|
msg.add_attachment(
|
||||||
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
|
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
|
||||||
maintype="application",
|
maintype="application",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "https://multimailer.local/schema/campaign.schema.json",
|
"$id": "https://govoplan.local/schema/campaign.schema.json",
|
||||||
"title": "MultiMailer Campaign",
|
"title": "GovOPlaN Campaign",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"version",
|
"version",
|
||||||
@@ -419,7 +419,19 @@
|
|||||||
"send_without_attachments": {
|
"send_without_attachments": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
"description": "Legacy compatibility flag. Prefer validation_policy and per-config missing_behavior for new campaigns."
|
"description": "Legacy compatibility flag. Use send_without_attachments_behavior for new campaigns."
|
||||||
|
},
|
||||||
|
"send_without_attachments_behavior": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"block",
|
||||||
|
"ask",
|
||||||
|
"drop",
|
||||||
|
"continue",
|
||||||
|
"warn"
|
||||||
|
],
|
||||||
|
"default": "continue",
|
||||||
|
"description": "Campaign-wide behavior when no attachment file is resolved for an active message."
|
||||||
},
|
},
|
||||||
"zip": {
|
"zip": {
|
||||||
"$ref": "#/$defs/zip_collection_config",
|
"$ref": "#/$defs/zip_collection_config",
|
||||||
@@ -1074,9 +1086,32 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"csv",
|
"csv",
|
||||||
"xlsx",
|
"xlsx",
|
||||||
"text"
|
"text",
|
||||||
|
"addresses"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"source_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"source_label": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"source_revision": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"source_provenance": {
|
||||||
|
"type": "object",
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
"filename": {
|
"filename": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
|
|||||||
@@ -266,6 +266,65 @@ class RecipientImportMappingProfileListResponse(BaseModel):
|
|||||||
profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list)
|
profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignAddressLookupCandidate(BaseModel):
|
||||||
|
contact_id: str
|
||||||
|
address_book_id: str
|
||||||
|
display_name: str
|
||||||
|
email: str | None = None
|
||||||
|
email_label: str | None = None
|
||||||
|
organization: str | None = None
|
||||||
|
role_title: str | None = None
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
source_kind: str = "local"
|
||||||
|
source_ref: str | None = None
|
||||||
|
source_revision: str | None = None
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignAddressLookupResponse(BaseModel):
|
||||||
|
available: bool = False
|
||||||
|
candidates: list[CampaignAddressLookupCandidate] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRecipientAddressSource(BaseModel):
|
||||||
|
source_id: str
|
||||||
|
source_label: str
|
||||||
|
source_kind: str
|
||||||
|
source_revision: str
|
||||||
|
recipient_count: int = 0
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRecipientAddressSourcesResponse(BaseModel):
|
||||||
|
available: bool = False
|
||||||
|
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
source_id: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRecipientSnapshotItem(BaseModel):
|
||||||
|
contact_id: str
|
||||||
|
display_name: str
|
||||||
|
email: str
|
||||||
|
email_label: str | None = None
|
||||||
|
fields: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRecipientAddressSourceSnapshotResponse(BaseModel):
|
||||||
|
source_id: str
|
||||||
|
source_label: str
|
||||||
|
source_kind: str
|
||||||
|
source_revision: str
|
||||||
|
generated_at: str
|
||||||
|
recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list)
|
||||||
|
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class CampaignJobsResponse(BaseModel):
|
class CampaignJobsResponse(BaseModel):
|
||||||
jobs: list[dict[str, Any]]
|
jobs: list[dict[str, Any]]
|
||||||
page: int = 1
|
page: int = 1
|
||||||
@@ -312,6 +371,15 @@ class CampaignSendUnattemptedRequest(BaseModel):
|
|||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignSendJobRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
include_warnings: bool = True
|
||||||
|
dry_run: bool = False
|
||||||
|
use_rate_limit: bool = True
|
||||||
|
enqueue_imap_task: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CampaignResolveOutcomeRequest(BaseModel):
|
class CampaignResolveOutcomeRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -323,6 +391,7 @@ class ValidateCampaignRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
check_files: bool = False
|
check_files: bool = False
|
||||||
|
link_unshared_matches: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BuildCampaignRequest(BaseModel):
|
class BuildCampaignRequest(BaseModel):
|
||||||
|
|||||||
@@ -219,8 +219,10 @@ def create_execution_snapshot(
|
|||||||
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
||||||
redacted_smtp = _redacted_transport_config(smtp)
|
redacted_smtp = _redacted_transport_config(smtp)
|
||||||
redacted_imap = _redacted_transport_config(imap)
|
redacted_imap = _redacted_transport_config(imap)
|
||||||
assert isinstance(redacted_smtp, SmtpConfig)
|
if not isinstance(redacted_smtp, SmtpConfig):
|
||||||
assert redacted_imap is None or isinstance(redacted_imap, ImapConfig)
|
raise ExecutionSnapshotError("Redacted SMTP configuration is invalid.")
|
||||||
|
if redacted_imap is not None and not isinstance(redacted_imap, ImapConfig):
|
||||||
|
raise ExecutionSnapshotError("Redacted IMAP configuration is invalid.")
|
||||||
payload = ExecutionSnapshot(
|
payload = ExecutionSnapshot(
|
||||||
campaign_version_id=version.id,
|
campaign_version_id=version.id,
|
||||||
campaign_json_sha256=_sha256(raw_json),
|
campaign_json_sha256=_sha256(raw_json),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||||
from govoplan_core.settings import settings as core_settings
|
from govoplan_core.settings import settings as core_settings
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
@@ -28,6 +29,7 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
SendAttempt,
|
SendAttempt,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
|
||||||
|
from govoplan_campaign.backend.runtime import get_registry
|
||||||
from govoplan_campaign.backend.integrations import (
|
from govoplan_campaign.backend.integrations import (
|
||||||
ImapAppendError,
|
ImapAppendError,
|
||||||
ImapConfigurationError,
|
ImapConfigurationError,
|
||||||
@@ -133,6 +135,25 @@ class AppendSentResult:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _CampaignDeliveryCounts:
|
||||||
|
accepted: int
|
||||||
|
unknown: int
|
||||||
|
failed: int
|
||||||
|
active: int
|
||||||
|
cancelled: int
|
||||||
|
not_started: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _SendJobDeliveryContext:
|
||||||
|
version: CampaignVersion
|
||||||
|
snapshot: ExecutionSnapshot
|
||||||
|
message_bytes: bytes
|
||||||
|
envelope_from: str
|
||||||
|
envelope_recipients: list[str]
|
||||||
|
|
||||||
|
|
||||||
QUEUEABLE_VALIDATION_STATUSES = {
|
QUEUEABLE_VALIDATION_STATUSES = {
|
||||||
JobValidationStatus.READY.value,
|
JobValidationStatus.READY.value,
|
||||||
JobValidationStatus.WARNING.value,
|
JobValidationStatus.WARNING.value,
|
||||||
@@ -140,6 +161,15 @@ QUEUEABLE_VALIDATION_STATUSES = {
|
|||||||
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
|
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
|
||||||
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
|
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
|
||||||
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
|
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
|
||||||
|
CAMPAIGN_STATUS_NOTIFICATION_EVENTS = {
|
||||||
|
CampaignStatus.QUEUED.value: ("campaign.queued", "Campaign queued", 1),
|
||||||
|
CampaignStatus.SENDING.value: ("campaign.sending", "Campaign sending started", 1),
|
||||||
|
CampaignStatus.SENT.value: ("campaign.sent", "Campaign sent", 1),
|
||||||
|
CampaignStatus.PARTIALLY_COMPLETED.value: ("campaign.partially_completed", "Campaign partially completed", 4),
|
||||||
|
CampaignStatus.OUTCOME_UNKNOWN.value: ("campaign.outcome_unknown", "Campaign requires review", 5),
|
||||||
|
CampaignStatus.FAILED.value: ("campaign.failed", "Campaign failed", 5),
|
||||||
|
CampaignStatus.CANCELLED.value: ("campaign.cancelled", "Campaign cancelled", 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||||
@@ -168,6 +198,22 @@ def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
|||||||
raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
raise QueueingError("Campaign version must be validated and locked before building, queueing, dry-run or sending.")
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewed_needs_review_keys(version: CampaignVersion) -> set[str]:
|
||||||
|
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 {}
|
||||||
|
if not build_token or str(review_state.get("build_token") or "") != build_token:
|
||||||
|
return set()
|
||||||
|
if review_state.get("inspection_complete") is not True:
|
||||||
|
return set()
|
||||||
|
return {str(value) for value in (review_state.get("reviewed_message_keys") or []) if str(value).strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def _job_review_key(job: CampaignJob) -> str:
|
||||||
|
return str(job.entry_id or job.entry_index)
|
||||||
|
|
||||||
|
|
||||||
def _utcnow() -> datetime:
|
def _utcnow() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -210,16 +256,72 @@ def _should_enqueue_celery(enqueue_celery: bool) -> bool:
|
|||||||
return bool(enqueue_celery and _celery_enabled())
|
return bool(enqueue_celery and _celery_enabled())
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_notification_body(campaign: Campaign, status: str) -> str:
|
||||||
|
return {
|
||||||
|
CampaignStatus.QUEUED.value: f"{campaign.name} has been queued for delivery.",
|
||||||
|
CampaignStatus.SENDING.value: f"{campaign.name} started sending.",
|
||||||
|
CampaignStatus.SENT.value: f"{campaign.name} finished sending.",
|
||||||
|
CampaignStatus.PARTIALLY_COMPLETED.value: f"{campaign.name} finished with partial delivery results. Review the campaign report.",
|
||||||
|
CampaignStatus.OUTCOME_UNKNOWN.value: f"{campaign.name} has unresolved delivery outcomes and needs operator review.",
|
||||||
|
CampaignStatus.FAILED.value: f"{campaign.name} failed during delivery. Review the failed jobs.",
|
||||||
|
CampaignStatus.CANCELLED.value: f"{campaign.name} was cancelled.",
|
||||||
|
}.get(status, f"{campaign.name} changed status to {status}.")
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_campaign_status_notification(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
status: str,
|
||||||
|
previous_status: str | None = None,
|
||||||
|
version_id: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
provider = notification_dispatch_provider(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
|
event = CAMPAIGN_STATUS_NOTIFICATION_EVENTS.get(status)
|
||||||
|
if event is None:
|
||||||
|
return
|
||||||
|
event_kind, title, priority = event
|
||||||
|
try:
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
source_module="campaigns",
|
||||||
|
source_resource_type="campaign",
|
||||||
|
source_resource_id=campaign.id,
|
||||||
|
event_kind=event_kind,
|
||||||
|
channel="inbox",
|
||||||
|
subject=f"{title}: {campaign.name}",
|
||||||
|
body_text=message or _campaign_notification_body(campaign, status),
|
||||||
|
action_url=f"/campaigns/{campaign.id}",
|
||||||
|
priority=priority,
|
||||||
|
payload={
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"version_id": version_id or campaign.current_version_id,
|
||||||
|
"status": status,
|
||||||
|
"previous_status": previous_status,
|
||||||
|
},
|
||||||
|
metadata={"campaign_id": campaign.id, "version_id": version_id or campaign.current_version_id},
|
||||||
|
),
|
||||||
|
enqueue_delivery=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _celery_enqueue_send_job(job_id: str) -> None:
|
def _celery_enqueue_send_job(job_id: str) -> None:
|
||||||
from govoplan_core.celery_app import celery
|
from govoplan_core.celery_app import celery
|
||||||
|
|
||||||
celery.send_task("multimailer.send_email", args=[job_id], queue="send_email")
|
celery.send_task("govoplan.campaigns.send_email", args=[job_id], queue="send_email")
|
||||||
|
|
||||||
|
|
||||||
def _celery_enqueue_append_sent_job(job_id: str) -> None:
|
def _celery_enqueue_append_sent_job(job_id: str) -> None:
|
||||||
from govoplan_core.celery_app import celery
|
from govoplan_core.celery_app import celery
|
||||||
|
|
||||||
celery.send_task("multimailer.append_sent", args=[job_id], queue="append_sent")
|
celery.send_task("govoplan.campaigns.append_sent", args=[job_id], queue="append_sent")
|
||||||
|
|
||||||
|
|
||||||
def queue_campaign_jobs(
|
def queue_campaign_jobs(
|
||||||
@@ -256,6 +358,7 @@ def queue_campaign_jobs(
|
|||||||
raise QueueingError("Campaign version has no jobs. Build messages before queueing.")
|
raise QueueingError("Campaign version has no jobs. Build messages before queueing.")
|
||||||
|
|
||||||
queued: list[CampaignJob] = []
|
queued: list[CampaignJob] = []
|
||||||
|
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
blocked_count = 0
|
blocked_count = 0
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
@@ -274,7 +377,11 @@ def queue_campaign_jobs(
|
|||||||
if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value}:
|
if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value}:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
continue
|
continue
|
||||||
if job.build_status != JobBuildStatus.BUILT.value or job.validation_status not in allowed_validation:
|
validation_allowed = job.validation_status in allowed_validation or (
|
||||||
|
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
|
||||||
|
and _job_review_key(job) in reviewed_needs_review_keys
|
||||||
|
)
|
||||||
|
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
|
||||||
blocked_count += 1
|
blocked_count += 1
|
||||||
continue
|
continue
|
||||||
if not job.eml_local_path and not job.eml_storage_key:
|
if not job.eml_local_path and not job.eml_storage_key:
|
||||||
@@ -296,11 +403,20 @@ def queue_campaign_jobs(
|
|||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
if queued:
|
if queued:
|
||||||
|
previous_status = campaign.status
|
||||||
campaign.status = CampaignStatus.QUEUED.value
|
campaign.status = CampaignStatus.QUEUED.value
|
||||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||||
if version.locked_at is None:
|
if version.locked_at is None:
|
||||||
version.locked_at = _utcnow()
|
version.locked_at = _utcnow()
|
||||||
session.add(version)
|
session.add(version)
|
||||||
|
if previous_status != campaign.status:
|
||||||
|
_emit_campaign_status_notification(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
status=campaign.status,
|
||||||
|
previous_status=previous_status,
|
||||||
|
version_id=version.id,
|
||||||
|
)
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -469,8 +585,16 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str,
|
|||||||
job.send_status = JobSendStatus.QUEUED.value
|
job.send_status = JobSendStatus.QUEUED.value
|
||||||
session.add(job)
|
session.add(job)
|
||||||
if jobs:
|
if jobs:
|
||||||
|
previous_status = campaign.status
|
||||||
campaign.status = CampaignStatus.QUEUED.value
|
campaign.status = CampaignStatus.QUEUED.value
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
|
if previous_status != campaign.status:
|
||||||
|
_emit_campaign_status_notification(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
status=campaign.status,
|
||||||
|
previous_status=previous_status,
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
enqueued_count = 0
|
enqueued_count = 0
|
||||||
@@ -675,6 +799,143 @@ def queue_unattempted_jobs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def send_single_campaign_job(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
job_id: str,
|
||||||
|
include_warnings: bool = True,
|
||||||
|
dry_run: bool = False,
|
||||||
|
use_rate_limit: bool = True,
|
||||||
|
enqueue_imap_task: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Explicitly queue and send one built recipient message through the audit send path."""
|
||||||
|
|
||||||
|
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")
|
||||||
|
version = _get_current_version(session, campaign, version_id=job.campaign_version_id)
|
||||||
|
_ensure_version_validated_and_locked(version)
|
||||||
|
ensure_execution_snapshot(session, version)
|
||||||
|
queue_action = _prepare_single_job_for_send(
|
||||||
|
session,
|
||||||
|
version=version,
|
||||||
|
job=job,
|
||||||
|
include_warnings=include_warnings,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
context = _send_job_delivery_context(session, job)
|
||||||
|
return {
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"version_id": version.id,
|
||||||
|
"job_id": job.id,
|
||||||
|
"action": "single_send",
|
||||||
|
"queue_action": queue_action,
|
||||||
|
"dry_run": True,
|
||||||
|
"result": SendJobResult(
|
||||||
|
job_id=job.id,
|
||||||
|
status="dry_run",
|
||||||
|
attempt_number=job.attempt_count,
|
||||||
|
dry_run=True,
|
||||||
|
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
|
||||||
|
).as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if queue_action == "queued":
|
||||||
|
previous_status = campaign.status
|
||||||
|
campaign.status = CampaignStatus.QUEUED.value
|
||||||
|
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||||
|
session.add(campaign)
|
||||||
|
session.add(version)
|
||||||
|
session.commit()
|
||||||
|
if previous_status != campaign.status:
|
||||||
|
_emit_campaign_status_notification(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
status=campaign.status,
|
||||||
|
previous_status=previous_status,
|
||||||
|
version_id=version.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = send_campaign_job(
|
||||||
|
session,
|
||||||
|
job_id=job.id,
|
||||||
|
dry_run=False,
|
||||||
|
use_rate_limit=use_rate_limit,
|
||||||
|
enqueue_imap_task=enqueue_imap_task,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"version_id": version.id,
|
||||||
|
"job_id": job.id,
|
||||||
|
"action": "single_send",
|
||||||
|
"queue_action": queue_action,
|
||||||
|
"dry_run": False,
|
||||||
|
"result": result.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_single_job_for_send(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
version: CampaignVersion,
|
||||||
|
job: CampaignJob,
|
||||||
|
include_warnings: bool,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> str:
|
||||||
|
if job.queue_status == JobQueueStatus.QUEUED.value and job.send_status == JobSendStatus.QUEUED.value:
|
||||||
|
return "already_queued"
|
||||||
|
if job.send_status in SMTP_ACCEPTED_STATUSES:
|
||||||
|
raise QueueingError("This message has already been accepted by SMTP and cannot be sent again from preview.")
|
||||||
|
if job.send_status in {JobSendStatus.CLAIMED.value, JobSendStatus.SENDING.value, JobSendStatus.OUTCOME_UNKNOWN.value}:
|
||||||
|
raise QueueingError(f"This message is in delivery state {job.send_status}; reconcile or wait before sending it again.")
|
||||||
|
if job.send_status in {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}:
|
||||||
|
raise QueueingError("This message has failed before. Use the explicit retry action so the retry is visible in the delivery protocol.")
|
||||||
|
if job.attempt_count > 0:
|
||||||
|
raise QueueingError("This message already has SMTP attempts. Use retry or reconciliation instead of preview send.")
|
||||||
|
if job.build_status != JobBuildStatus.BUILT.value:
|
||||||
|
raise QueueingError("This message has not been built yet.")
|
||||||
|
if not _single_job_validation_allowed(version, job, include_warnings=include_warnings):
|
||||||
|
raise QueueingError(f"This message cannot be sent while validation status is {job.validation_status}.")
|
||||||
|
if not job.eml_local_path and not job.eml_storage_key:
|
||||||
|
raise QueueingError("This message has no generated EML evidence. Rebuild the campaign before sending.")
|
||||||
|
if job.queue_status not in {JobQueueStatus.DRAFT.value, JobQueueStatus.CANCELLED.value}:
|
||||||
|
raise QueueingError(f"This message cannot be sent from queue state {job.queue_status}.")
|
||||||
|
if dry_run:
|
||||||
|
return "would_queue"
|
||||||
|
|
||||||
|
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)
|
||||||
|
return "queued"
|
||||||
|
|
||||||
|
|
||||||
|
def _single_job_validation_allowed(
|
||||||
|
version: CampaignVersion,
|
||||||
|
job: CampaignJob,
|
||||||
|
*,
|
||||||
|
include_warnings: bool,
|
||||||
|
) -> bool:
|
||||||
|
allowed = {JobValidationStatus.READY.value}
|
||||||
|
if include_warnings:
|
||||||
|
allowed.add(JobValidationStatus.WARNING.value)
|
||||||
|
if job.validation_status in allowed:
|
||||||
|
return True
|
||||||
|
return (
|
||||||
|
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
|
||||||
|
and _job_review_key(job) in _reviewed_needs_review_keys(version)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def reconcile_job_outcome(
|
def reconcile_job_outcome(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -909,27 +1170,54 @@ def _update_campaign_after_job(session: Session, campaign_id: str, version_id: s
|
|||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
if not campaign:
|
if not campaign:
|
||||||
return
|
return
|
||||||
base_filters = [CampaignJob.campaign_id == campaign_id]
|
previous_status = campaign.status
|
||||||
if version_id:
|
version = session.get(CampaignVersion, version_id) if version_id else None
|
||||||
base_filters.append(CampaignJob.campaign_version_id == version_id)
|
counts = _campaign_delivery_counts(session, campaign_id=campaign_id, version_id=version_id)
|
||||||
|
_apply_campaign_delivery_status(campaign, version, counts)
|
||||||
|
if version and (counts.accepted or counts.unknown):
|
||||||
|
if version.locked_at is None:
|
||||||
|
version.locked_at = _utcnow()
|
||||||
|
session.add(version)
|
||||||
|
session.add(campaign)
|
||||||
|
if campaign.status != previous_status:
|
||||||
|
_emit_campaign_status_notification(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
status=campaign.status,
|
||||||
|
previous_status=previous_status,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_delivery_counts(session: Session, *, campaign_id: str, version_id: str | None) -> _CampaignDeliveryCounts:
|
||||||
|
base_filters = _campaign_delivery_base_filters(campaign_id=campaign_id, version_id=version_id)
|
||||||
counts = {
|
counts = {
|
||||||
status: session.query(CampaignJob).filter(*base_filters, CampaignJob.send_status == status).count()
|
status: session.query(CampaignJob).filter(*base_filters, CampaignJob.send_status == status).count()
|
||||||
for status in {item.value for item in JobSendStatus}
|
for status in {item.value for item in JobSendStatus}
|
||||||
}
|
}
|
||||||
accepted = sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES)
|
return _CampaignDeliveryCounts(
|
||||||
unknown = counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0)
|
accepted=sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES),
|
||||||
failed = counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0)
|
unknown=counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0),
|
||||||
active = (
|
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.QUEUED.value, 0)
|
||||||
+ counts.get(JobSendStatus.CLAIMED.value, 0)
|
+ counts.get(JobSendStatus.CLAIMED.value, 0)
|
||||||
+ counts.get(JobSendStatus.SENDING.value, 0)
|
+ counts.get(JobSendStatus.SENDING.value, 0)
|
||||||
|
),
|
||||||
|
cancelled=counts.get(JobSendStatus.CANCELLED.value, 0),
|
||||||
|
not_started=_queueable_not_started_job_count(session, base_filters),
|
||||||
)
|
)
|
||||||
cancelled = counts.get(JobSendStatus.CANCELLED.value, 0)
|
|
||||||
# Blocked/excluded build rows are reportable but are not delivery attempts.
|
|
||||||
# Only a built, queueable message counts as an unattempted part of an
|
def _campaign_delivery_base_filters(*, campaign_id: str, version_id: str | None) -> list[object]:
|
||||||
# execution when deriving a partial outcome.
|
base_filters: list[object] = [CampaignJob.campaign_id == campaign_id]
|
||||||
not_started = (
|
if version_id:
|
||||||
|
base_filters.append(CampaignJob.campaign_version_id == version_id)
|
||||||
|
return base_filters
|
||||||
|
|
||||||
|
|
||||||
|
def _queueable_not_started_job_count(session: Session, base_filters: list[object]) -> int:
|
||||||
|
return (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
.filter(
|
.filter(
|
||||||
*base_filters,
|
*base_filters,
|
||||||
@@ -940,37 +1228,35 @@ def _update_campaign_after_job(session: Session, campaign_id: str, version_id: s
|
|||||||
.count()
|
.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):
|
def _apply_campaign_delivery_status(
|
||||||
if version.locked_at is None:
|
campaign: Campaign,
|
||||||
version.locked_at = _utcnow()
|
version: CampaignVersion | None,
|
||||||
session.add(version)
|
counts: _CampaignDeliveryCounts,
|
||||||
session.add(campaign)
|
) -> None:
|
||||||
|
status_pair = _campaign_delivery_status_pair(counts)
|
||||||
|
if status_pair is None:
|
||||||
|
return
|
||||||
|
campaign_status, workflow_state = status_pair
|
||||||
|
campaign.status = campaign_status
|
||||||
|
if version:
|
||||||
|
version.workflow_state = workflow_state
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_delivery_status_pair(counts: _CampaignDeliveryCounts) -> tuple[str, str] | None:
|
||||||
|
if counts.active:
|
||||||
|
return CampaignStatus.SENDING.value, CampaignVersionWorkflowState.SENDING.value
|
||||||
|
if counts.accepted and (counts.failed or counts.unknown or counts.cancelled or counts.not_started):
|
||||||
|
return CampaignStatus.PARTIALLY_COMPLETED.value, CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value
|
||||||
|
if counts.unknown:
|
||||||
|
return CampaignStatus.OUTCOME_UNKNOWN.value, CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value
|
||||||
|
if counts.failed:
|
||||||
|
return CampaignStatus.FAILED.value, CampaignVersionWorkflowState.FAILED.value
|
||||||
|
if counts.accepted:
|
||||||
|
return CampaignStatus.SENT.value, CampaignVersionWorkflowState.COMPLETED.value
|
||||||
|
if counts.cancelled:
|
||||||
|
return CampaignStatus.CANCELLED.value, CampaignVersionWorkflowState.CANCELLED.value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def send_campaign_job(
|
def send_campaign_job(
|
||||||
@@ -984,15 +1270,49 @@ def send_campaign_job(
|
|||||||
job = session.get(CampaignJob, job_id)
|
job = session.get(CampaignJob, job_id)
|
||||||
if not job:
|
if not job:
|
||||||
raise SendJobError(f"Job not found: {job_id}")
|
raise SendJobError(f"Job not found: {job_id}")
|
||||||
|
preflight_result = _preflight_send_campaign_job(session, job, dry_run=dry_run)
|
||||||
|
if preflight_result is not None:
|
||||||
|
return preflight_result
|
||||||
|
|
||||||
|
context = _send_job_delivery_context(session, job)
|
||||||
|
if dry_run:
|
||||||
|
return SendJobResult(
|
||||||
|
job_id=job.id,
|
||||||
|
status="dry_run",
|
||||||
|
attempt_number=job.attempt_count,
|
||||||
|
dry_run=True,
|
||||||
|
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
|
||||||
|
)
|
||||||
|
|
||||||
|
claimed = _claimed_campaign_job_for_delivery(session, job)
|
||||||
|
if isinstance(claimed, SendJobResult):
|
||||||
|
return claimed
|
||||||
|
claimed_job, claim_token = claimed
|
||||||
|
return _send_claimed_campaign_job(
|
||||||
|
session,
|
||||||
|
job=claimed_job,
|
||||||
|
claim_token=claim_token,
|
||||||
|
context=context,
|
||||||
|
use_rate_limit=use_rate_limit,
|
||||||
|
enqueue_imap_task=enqueue_imap_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preflight_send_campaign_job(
|
||||||
|
session: Session,
|
||||||
|
job: CampaignJob,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> SendJobResult | None:
|
||||||
if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value:
|
if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value:
|
||||||
return SendJobResult(job_id=job_id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
|
return SendJobResult(job_id=job.id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
|
||||||
if job.queue_status == JobQueueStatus.PAUSED.value:
|
if job.queue_status == JobQueueStatus.PAUSED.value:
|
||||||
return SendJobResult(job_id=job_id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
|
return SendJobResult(job_id=job.id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
|
||||||
if job.send_status in SMTP_ACCEPTED_STATUSES:
|
if job.send_status in SMTP_ACCEPTED_STATUSES:
|
||||||
return SendJobResult(job_id=job_id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run)
|
return SendJobResult(job_id=job.id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run)
|
||||||
if job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
if job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
||||||
return SendJobResult(
|
return SendJobResult(
|
||||||
job_id=job_id,
|
job_id=job.id,
|
||||||
status=JobSendStatus.OUTCOME_UNKNOWN.value,
|
status=JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||||
attempt_number=job.attempt_count,
|
attempt_number=job.attempt_count,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
@@ -1006,7 +1326,7 @@ def send_campaign_job(
|
|||||||
)
|
)
|
||||||
if job.send_status == JobSendStatus.CLAIMED.value:
|
if job.send_status == JobSendStatus.CLAIMED.value:
|
||||||
return SendJobResult(
|
return SendJobResult(
|
||||||
job_id=job_id,
|
job_id=job.id,
|
||||||
status="already_claimed",
|
status="already_claimed",
|
||||||
attempt_number=job.attempt_count,
|
attempt_number=job.attempt_count,
|
||||||
dry_run=dry_run,
|
dry_run=dry_run,
|
||||||
@@ -1014,7 +1334,10 @@ def send_campaign_job(
|
|||||||
)
|
)
|
||||||
if job.queue_status != JobQueueStatus.QUEUED.value or job.send_status != JobSendStatus.QUEUED.value:
|
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}")
|
raise SendJobError(f"Job is not explicitly queued for delivery: queue={job.queue_status}, send={job.send_status}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDeliveryContext:
|
||||||
version = session.get(CampaignVersion, job.campaign_version_id)
|
version = session.get(CampaignVersion, job.campaign_version_id)
|
||||||
if not version:
|
if not version:
|
||||||
raise SendJobError("Campaign version not found")
|
raise SendJobError("Campaign version not found")
|
||||||
@@ -1028,18 +1351,30 @@ def send_campaign_job(
|
|||||||
envelope_recipients = _recipients_from_job(job)
|
envelope_recipients = _recipients_from_job(job)
|
||||||
if not envelope_recipients:
|
if not envelope_recipients:
|
||||||
raise SmtpConfigurationError("No envelope recipients could be determined")
|
raise SmtpConfigurationError("No envelope recipients could be determined")
|
||||||
|
return _SendJobDeliveryContext(
|
||||||
if dry_run:
|
version=version,
|
||||||
return SendJobResult(
|
snapshot=snapshot,
|
||||||
job_id=job.id,
|
message_bytes=message_bytes,
|
||||||
status="dry_run",
|
envelope_from=envelope_from,
|
||||||
attempt_number=job.attempt_count,
|
envelope_recipients=envelope_recipients,
|
||||||
dry_run=True,
|
|
||||||
message=f"Would send to {len(envelope_recipients)} recipient(s) from {envelope_from}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _claimed_campaign_job_for_delivery(
|
||||||
|
session: Session,
|
||||||
|
job: CampaignJob,
|
||||||
|
) -> tuple[CampaignJob, str] | SendJobResult:
|
||||||
claim_token = _claim_job_for_sending(session, job)
|
claim_token = _claim_job_for_sending(session, job)
|
||||||
if claim_token is None:
|
if claim_token is None:
|
||||||
|
return _not_claimed_send_job_result(session, job)
|
||||||
|
|
||||||
|
job = session.get(CampaignJob, job.id)
|
||||||
|
if job is None:
|
||||||
|
raise SendJobError("Claimed campaign job disappeared before send.")
|
||||||
|
return job, claim_token
|
||||||
|
|
||||||
|
|
||||||
|
def _not_claimed_send_job_result(session: Session, job: CampaignJob) -> SendJobResult:
|
||||||
current = session.get(CampaignJob, job.id)
|
current = session.get(CampaignJob, job.id)
|
||||||
if not current:
|
if not current:
|
||||||
raise SendJobError(f"Job disappeared while claiming: {job.id}")
|
raise SendJobError(f"Job disappeared while claiming: {job.id}")
|
||||||
@@ -1056,39 +1391,69 @@ def send_campaign_job(
|
|||||||
message=f"Job is no longer queueable: {current.send_status}",
|
message=f"Job is no longer queueable: {current.send_status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
job = session.get(CampaignJob, job.id)
|
|
||||||
assert job is not None
|
def _send_claimed_campaign_job(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job: CampaignJob,
|
||||||
|
claim_token: str,
|
||||||
|
context: _SendJobDeliveryContext,
|
||||||
|
use_rate_limit: bool,
|
||||||
|
enqueue_imap_task: bool,
|
||||||
|
) -> SendJobResult:
|
||||||
mail_integration().wait_for_rate_limit(
|
mail_integration().wait_for_rate_limit(
|
||||||
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
|
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
|
||||||
messages_per_minute=snapshot.delivery.rate_limit.messages_per_minute,
|
messages_per_minute=context.snapshot.delivery.rate_limit.messages_per_minute,
|
||||||
enabled=use_rate_limit,
|
enabled=use_rate_limit,
|
||||||
)
|
)
|
||||||
attempt = _record_attempt_start(session, job, claim_token)
|
attempt = _record_attempt_start(session, job, claim_token)
|
||||||
try:
|
try:
|
||||||
smtp_config = runtime_smtp_config(session, version, snapshot)
|
smtp_config = runtime_smtp_config(session, context.version, context.snapshot)
|
||||||
mail_integration().assert_mail_policy_allows_send(
|
mail_integration().assert_mail_policy_allows_send(
|
||||||
session,
|
session,
|
||||||
tenant_id=job.tenant_id,
|
tenant_id=job.tenant_id,
|
||||||
campaign_id=job.campaign_id,
|
campaign_id=job.campaign_id,
|
||||||
smtp=smtp_config,
|
smtp=smtp_config,
|
||||||
envelope_sender=envelope_from,
|
envelope_sender=context.envelope_from,
|
||||||
from_header=_from_header_from_job(job, snapshot),
|
from_header=_from_header_from_job(job, context.snapshot),
|
||||||
recipients=envelope_recipients,
|
recipients=context.envelope_recipients,
|
||||||
)
|
)
|
||||||
result = mail_integration().send_email_bytes(
|
result = mail_integration().send_email_bytes(
|
||||||
message_bytes,
|
context.message_bytes,
|
||||||
smtp_config=smtp_config,
|
smtp_config=smtp_config,
|
||||||
envelope_from=envelope_from,
|
envelope_from=context.envelope_from,
|
||||||
envelope_recipients=envelope_recipients,
|
envelope_recipients=context.envelope_recipients,
|
||||||
)
|
)
|
||||||
|
return _record_smtp_send_success(
|
||||||
|
session,
|
||||||
|
job=job,
|
||||||
|
attempt=attempt,
|
||||||
|
snapshot=context.snapshot,
|
||||||
|
result=result,
|
||||||
|
enqueue_imap_task=enqueue_imap_task,
|
||||||
|
)
|
||||||
|
except SmtpSendError as exc:
|
||||||
|
outcome_unknown = _record_smtp_send_error(session, job=job, attempt=attempt, exc=exc)
|
||||||
|
if outcome_unknown is not None:
|
||||||
|
return outcome_unknown
|
||||||
|
raise
|
||||||
|
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
|
||||||
|
_record_permanent_send_error(session, job=job, attempt=attempt, exc=exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _record_smtp_send_success(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job: CampaignJob,
|
||||||
|
attempt: SendAttempt,
|
||||||
|
snapshot: ExecutionSnapshot,
|
||||||
|
result: object,
|
||||||
|
enqueue_imap_task: bool,
|
||||||
|
) -> SendJobResult:
|
||||||
if result.accepted_count <= 0:
|
if result.accepted_count <= 0:
|
||||||
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
|
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
|
||||||
refused_warning = None
|
refused_warning = _refused_recipient_warning(result)
|
||||||
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.finished_at = _utcnow()
|
||||||
attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value
|
attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value
|
||||||
attempt.smtp_response = json.dumps(asdict(result), default=str)
|
attempt.smtp_response = json.dumps(asdict(result), default=str)
|
||||||
@@ -1097,10 +1462,7 @@ def send_campaign_job(
|
|||||||
job.sent_at = _utcnow()
|
job.sent_at = _utcnow()
|
||||||
job.claim_token = None
|
job.claim_token = None
|
||||||
job.outcome_unknown_at = None
|
job.outcome_unknown_at = None
|
||||||
if snapshot.delivery.imap_append_sent.enabled:
|
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
|
||||||
job.imap_status = JobImapStatus.PENDING.value
|
|
||||||
else:
|
|
||||||
job.imap_status = JobImapStatus.NOT_REQUESTED.value
|
|
||||||
job.last_error = refused_warning
|
job.last_error = refused_warning
|
||||||
files_integration().mark_job_attachment_uses_sent(session, job)
|
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||||
session.add(attempt)
|
session.add(attempt)
|
||||||
@@ -1116,7 +1478,23 @@ def send_campaign_job(
|
|||||||
message=refused_warning,
|
message=refused_warning,
|
||||||
)
|
)
|
||||||
|
|
||||||
except SmtpSendError as exc:
|
|
||||||
|
def _refused_recipient_warning(result: object) -> str | None:
|
||||||
|
if not result.refused_recipients:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"SMTP accepted {result.accepted_count}/{len(result.envelope_recipients)} envelope recipient(s); "
|
||||||
|
f"refused recipients: {json.dumps(result.refused_recipients, default=str, sort_keys=True)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_smtp_send_error(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job: CampaignJob,
|
||||||
|
attempt: SendAttempt,
|
||||||
|
exc: SmtpSendError,
|
||||||
|
) -> SendJobResult | None:
|
||||||
if getattr(exc, "outcome_unknown", False):
|
if getattr(exc, "outcome_unknown", False):
|
||||||
attempt.status = JobSendStatus.OUTCOME_UNKNOWN.value
|
attempt.status = JobSendStatus.OUTCOME_UNKNOWN.value
|
||||||
attempt.finished_at = _utcnow()
|
attempt.finished_at = _utcnow()
|
||||||
@@ -1141,8 +1519,16 @@ def send_campaign_job(
|
|||||||
session.add(job)
|
session.add(job)
|
||||||
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
||||||
session.commit()
|
session.commit()
|
||||||
raise
|
return None
|
||||||
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
|
|
||||||
|
|
||||||
|
def _record_permanent_send_error(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
job: CampaignJob,
|
||||||
|
attempt: SendAttempt,
|
||||||
|
exc: Exception,
|
||||||
|
) -> None:
|
||||||
attempt.finished_at = _utcnow()
|
attempt.finished_at = _utcnow()
|
||||||
attempt.error_type = exc.__class__.__name__
|
attempt.error_type = exc.__class__.__name__
|
||||||
attempt.error_message = str(exc)
|
attempt.error_message = str(exc)
|
||||||
@@ -1155,7 +1541,6 @@ def send_campaign_job(
|
|||||||
session.add(job)
|
session.add(job)
|
||||||
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
||||||
session.commit()
|
session.commit()
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt:
|
def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt:
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import binascii
|
||||||
|
from datetime import datetime
|
||||||
|
import secrets
|
||||||
|
import stat
|
||||||
|
import struct
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
import zlib
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pyzipper
|
import pyzipper
|
||||||
@@ -10,6 +16,8 @@ except ImportError: # pragma: no cover
|
|||||||
pyzipper = None
|
pyzipper = None
|
||||||
|
|
||||||
ArchiveMember = tuple[Path, str]
|
ArchiveMember = tuple[Path, str]
|
||||||
|
ZIP_METHOD_AES = "aes"
|
||||||
|
ZIP_METHOD_STANDARD = "zip_standard"
|
||||||
|
|
||||||
|
|
||||||
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
|
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
|
||||||
@@ -34,14 +42,34 @@ def create_zip_archive(
|
|||||||
output_path: Path,
|
output_path: Path,
|
||||||
files: Iterable[Path | ArchiveMember],
|
files: Iterable[Path | ArchiveMember],
|
||||||
password: str = "",
|
password: str = "",
|
||||||
|
method: str = ZIP_METHOD_AES,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Create a ZIP archive, using AES encryption when a password is supplied."""
|
"""Create a ZIP archive, optionally using AES or legacy ZipCrypto encryption."""
|
||||||
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
members = _normalized_members(files)
|
members = _normalized_members(files)
|
||||||
if password:
|
if password:
|
||||||
|
if method == ZIP_METHOD_STANDARD:
|
||||||
|
_create_zipcrypto_archive(output_path, members, password)
|
||||||
|
return output_path
|
||||||
|
_create_aes_archive(output_path, members, password)
|
||||||
|
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, method: str = ZIP_METHOD_AES) -> Path:
|
||||||
|
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
||||||
|
|
||||||
|
return create_zip_archive(output_path, files, password, method)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_aes_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None:
|
||||||
if pyzipper is None:
|
if pyzipper is None:
|
||||||
raise RuntimeError("pyzipper is required for writing password-protected ZIP files")
|
raise RuntimeError("pyzipper is required for writing AES password-protected ZIP files")
|
||||||
with pyzipper.AESZipFile(
|
with pyzipper.AESZipFile(
|
||||||
output_path,
|
output_path,
|
||||||
"w",
|
"w",
|
||||||
@@ -51,15 +79,196 @@ def create_zip_archive(
|
|||||||
zip_file.setpassword(password.encode("utf-8"))
|
zip_file.setpassword(password.encode("utf-8"))
|
||||||
for file_path, archive_name in members:
|
for file_path, archive_name in members:
|
||||||
zip_file.write(file_path, arcname=archive_name)
|
zip_file.write(file_path, arcname=archive_name)
|
||||||
return output_path
|
|
||||||
|
|
||||||
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
|
|
||||||
|
def _create_zipcrypto_archive(output_path: Path, members: list[ArchiveMember], password: str) -> None:
|
||||||
|
entries: list[_CentralDirectoryEntry] = []
|
||||||
|
password_bytes = password.encode("utf-8")
|
||||||
|
with output_path.open("wb") as zip_file:
|
||||||
for file_path, archive_name in members:
|
for file_path, archive_name in members:
|
||||||
zip_file.write(file_path, arcname=archive_name)
|
local_header_offset = zip_file.tell()
|
||||||
return output_path
|
file_data = file_path.read_bytes()
|
||||||
|
crc = binascii.crc32(file_data) & 0xFFFFFFFF
|
||||||
|
compressed_data = _deflate(file_data)
|
||||||
|
encrypted_data = _zipcrypto_encrypt(compressed_data, password_bytes, crc)
|
||||||
|
compressed_size = len(encrypted_data)
|
||||||
|
uncompressed_size = len(file_data)
|
||||||
|
modified_at = datetime.fromtimestamp(file_path.stat().st_mtime)
|
||||||
|
dos_time, dos_date = _dos_timestamp(modified_at)
|
||||||
|
filename_bytes, flags = _filename_bytes(archive_name, encrypted=True)
|
||||||
|
|
||||||
|
zip_file.write(
|
||||||
|
struct.pack(
|
||||||
|
"<IHHHHHIIIHH",
|
||||||
|
0x04034B50,
|
||||||
|
20,
|
||||||
|
flags,
|
||||||
|
zipfile.ZIP_DEFLATED,
|
||||||
|
dos_time,
|
||||||
|
dos_date,
|
||||||
|
crc,
|
||||||
|
compressed_size,
|
||||||
|
uncompressed_size,
|
||||||
|
len(filename_bytes),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
zip_file.write(filename_bytes)
|
||||||
|
zip_file.write(encrypted_data)
|
||||||
|
entries.append(
|
||||||
|
_CentralDirectoryEntry(
|
||||||
|
filename_bytes=filename_bytes,
|
||||||
|
flags=flags,
|
||||||
|
dos_time=dos_time,
|
||||||
|
dos_date=dos_date,
|
||||||
|
crc=crc,
|
||||||
|
compressed_size=compressed_size,
|
||||||
|
uncompressed_size=uncompressed_size,
|
||||||
|
external_attributes=_external_attributes(file_path),
|
||||||
|
local_header_offset=local_header_offset,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
central_directory_offset = zip_file.tell()
|
||||||
|
for entry in entries:
|
||||||
|
zip_file.write(
|
||||||
|
struct.pack(
|
||||||
|
"<IHHHHHHIIIHHHHHII",
|
||||||
|
0x02014B50,
|
||||||
|
20,
|
||||||
|
20,
|
||||||
|
entry.flags,
|
||||||
|
zipfile.ZIP_DEFLATED,
|
||||||
|
entry.dos_time,
|
||||||
|
entry.dos_date,
|
||||||
|
entry.crc,
|
||||||
|
entry.compressed_size,
|
||||||
|
entry.uncompressed_size,
|
||||||
|
len(entry.filename_bytes),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
entry.external_attributes,
|
||||||
|
entry.local_header_offset,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
zip_file.write(entry.filename_bytes)
|
||||||
|
|
||||||
|
central_directory_size = zip_file.tell() - central_directory_offset
|
||||||
|
zip_file.write(
|
||||||
|
struct.pack(
|
||||||
|
"<IHHHHIIH",
|
||||||
|
0x06054B50,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
len(entries),
|
||||||
|
len(entries),
|
||||||
|
central_directory_size,
|
||||||
|
central_directory_offset,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path:
|
class _CentralDirectoryEntry:
|
||||||
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
filename_bytes: bytes,
|
||||||
|
flags: int,
|
||||||
|
dos_time: int,
|
||||||
|
dos_date: int,
|
||||||
|
crc: int,
|
||||||
|
compressed_size: int,
|
||||||
|
uncompressed_size: int,
|
||||||
|
external_attributes: int,
|
||||||
|
local_header_offset: int,
|
||||||
|
) -> None:
|
||||||
|
self.filename_bytes = filename_bytes
|
||||||
|
self.flags = flags
|
||||||
|
self.dos_time = dos_time
|
||||||
|
self.dos_date = dos_date
|
||||||
|
self.crc = crc
|
||||||
|
self.compressed_size = compressed_size
|
||||||
|
self.uncompressed_size = uncompressed_size
|
||||||
|
self.external_attributes = external_attributes
|
||||||
|
self.local_header_offset = local_header_offset
|
||||||
|
|
||||||
return create_zip_archive(output_path, files, password)
|
|
||||||
|
def _deflate(data: bytes) -> bytes:
|
||||||
|
compressor = zlib.compressobj(zlib.Z_DEFAULT_COMPRESSION, zlib.DEFLATED, -zlib.MAX_WBITS)
|
||||||
|
return compressor.compress(data) + compressor.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _zipcrypto_encrypt(data: bytes, password: bytes, crc: int) -> bytes:
|
||||||
|
cipher = _ZipCrypto(password)
|
||||||
|
header = secrets.token_bytes(11) + bytes([(crc >> 24) & 0xFF])
|
||||||
|
return cipher.encrypt(header + data)
|
||||||
|
|
||||||
|
|
||||||
|
class _ZipCrypto:
|
||||||
|
def __init__(self, password: bytes) -> None:
|
||||||
|
self.key0 = 0x12345678
|
||||||
|
self.key1 = 0x23456789
|
||||||
|
self.key2 = 0x34567890
|
||||||
|
for value in password:
|
||||||
|
self._update_keys(value)
|
||||||
|
|
||||||
|
def encrypt(self, data: bytes) -> bytes:
|
||||||
|
encrypted = bytearray()
|
||||||
|
for value in data:
|
||||||
|
encrypted.append(value ^ self._decrypt_byte())
|
||||||
|
self._update_keys(value)
|
||||||
|
return bytes(encrypted)
|
||||||
|
|
||||||
|
def _decrypt_byte(self) -> int:
|
||||||
|
temp = self.key2 | 2
|
||||||
|
return ((temp * (temp ^ 1)) >> 8) & 0xFF
|
||||||
|
|
||||||
|
def _update_keys(self, value: int) -> None:
|
||||||
|
self.key0 = _zipcrypto_crc32_byte(value, self.key0)
|
||||||
|
self.key1 = (self.key1 + (self.key0 & 0xFF)) & 0xFFFFFFFF
|
||||||
|
self.key1 = (self.key1 * 134775813 + 1) & 0xFFFFFFFF
|
||||||
|
self.key2 = _zipcrypto_crc32_byte((self.key1 >> 24) & 0xFF, self.key2)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_zipcrypto_crc(value: int) -> int:
|
||||||
|
for _ in range(8):
|
||||||
|
if value & 1:
|
||||||
|
value = 0xEDB88320 ^ (value >> 1)
|
||||||
|
else:
|
||||||
|
value >>= 1
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
_ZIPCRYPTO_CRC_TABLE = [_build_zipcrypto_crc(value) for value in range(256)]
|
||||||
|
|
||||||
|
|
||||||
|
def _zipcrypto_crc32_byte(value: int, crc: int) -> int:
|
||||||
|
return ((crc >> 8) ^ _ZIPCRYPTO_CRC_TABLE[(crc ^ value) & 0xFF]) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_bytes(filename: str, *, encrypted: bool) -> tuple[bytes, int]:
|
||||||
|
flags = 0x1 if encrypted else 0
|
||||||
|
try:
|
||||||
|
return filename.encode("ascii"), flags
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
return filename.encode("utf-8"), flags | 0x800
|
||||||
|
|
||||||
|
|
||||||
|
def _dos_timestamp(value: datetime) -> tuple[int, int]:
|
||||||
|
year = min(max(value.year, 1980), 2107)
|
||||||
|
month = value.month if value.year == year else 1
|
||||||
|
day = value.day if value.year == year else 1
|
||||||
|
dos_time = value.hour << 11 | value.minute << 5 | value.second // 2
|
||||||
|
dos_date = (year - 1980) << 9 | month << 5 | day
|
||||||
|
return dos_time, dos_date
|
||||||
|
|
||||||
|
|
||||||
|
def _external_attributes(path: Path) -> int:
|
||||||
|
try:
|
||||||
|
permissions = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
return permissions << 16
|
||||||
|
|||||||
206
tests/test_attachment_building.py
Normal file
206
tests/test_attachment_building.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||||
|
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||||
|
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||||
|
def _no_attachment_config(
|
||||||
|
self,
|
||||||
|
behavior: str | None = None,
|
||||||
|
legacy_allow: bool | None = None,
|
||||||
|
*,
|
||||||
|
configure_missing_rule: bool = False,
|
||||||
|
) -> CampaignConfig:
|
||||||
|
attachments: dict[str, object] = {
|
||||||
|
"base_path": ".",
|
||||||
|
"global": [],
|
||||||
|
"zip": {"enabled": False, "archives": []},
|
||||||
|
}
|
||||||
|
if configure_missing_rule:
|
||||||
|
attachments["global"] = [
|
||||||
|
{
|
||||||
|
"id": "missing",
|
||||||
|
"label": "Missing attachment",
|
||||||
|
"base_dir": ".",
|
||||||
|
"file_filter": "missing.pdf",
|
||||||
|
"required": False,
|
||||||
|
"missing_behavior": "continue",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if behavior is not None:
|
||||||
|
attachments["send_without_attachments_behavior"] = behavior
|
||||||
|
if legacy_allow is not None:
|
||||||
|
attachments["send_without_attachments"] = legacy_allow
|
||||||
|
return CampaignConfig.model_validate({
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": f"no-attachment-{behavior or legacy_allow}", "name": "No attachment policy", "mode": "test"},
|
||||||
|
"fields": [],
|
||||||
|
"global_values": {},
|
||||||
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||||
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
|
"template": {"subject": "Subject", "text": "Body"},
|
||||||
|
"attachments": attachments,
|
||||||
|
"entries": {"inline": [{"id": "recipient-1", "to": [{"email": "recipient@example.org", "type": "to"}]}]},
|
||||||
|
"validation_policy": {"missing_email": "block", "template_error": "block"},
|
||||||
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
campaign_file.write_text("{}", encoding="utf-8")
|
||||||
|
config = self._no_attachment_config(legacy_allow=False)
|
||||||
|
|
||||||
|
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||||
|
self.assertTrue(validation.ok)
|
||||||
|
self.assertNotIn("missing_attachment_coverage", {issue.code for issue in validation.issues})
|
||||||
|
|
||||||
|
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||||
|
self.assertEqual(result.report.build_failed_count, 0)
|
||||||
|
self.assertEqual(result.report.queueable_count, 1)
|
||||||
|
message = result.report.messages[0]
|
||||||
|
self.assertEqual(message.attachment_count, 0)
|
||||||
|
self.assertIsNotNone(message.eml_path)
|
||||||
|
self.assertNotIn("missing_attachment_coverage", {issue.code for issue in message.issues})
|
||||||
|
self.assertIsNotNone(result.built_messages[0].mime)
|
||||||
|
|
||||||
|
def test_configured_attachment_rule_blocks_generation_when_no_files_resolve(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
campaign_file.write_text("{}", encoding="utf-8")
|
||||||
|
config = self._no_attachment_config(behavior="block", configure_missing_rule=True)
|
||||||
|
|
||||||
|
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||||
|
self.assertFalse(validation.ok)
|
||||||
|
self.assertIn("missing_attachment_coverage", {issue.code for issue in validation.issues})
|
||||||
|
|
||||||
|
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||||
|
self.assertEqual(result.report.build_failed_count, 1)
|
||||||
|
self.assertEqual(result.report.queueable_count, 0)
|
||||||
|
message = result.report.messages[0]
|
||||||
|
self.assertEqual(message.attachment_count, 0)
|
||||||
|
self.assertIsNone(message.eml_path)
|
||||||
|
self.assertIn("missing_attachment_coverage", {issue.code for issue in message.issues})
|
||||||
|
self.assertIsNone(result.built_messages[0].mime)
|
||||||
|
|
||||||
|
def test_send_without_attachments_behavior_modes(self) -> None:
|
||||||
|
cases = {
|
||||||
|
"block": ("build_failed", "blocked", 0, "block", False),
|
||||||
|
"ask": ("built", "needs_review", 0, "ask", True),
|
||||||
|
"drop": ("built", "excluded", 0, "drop", True),
|
||||||
|
"warn": ("built", "warning", 1, "warn", True),
|
||||||
|
"continue": ("built", "ready", 1, None, True),
|
||||||
|
}
|
||||||
|
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
|
||||||
|
with self.subTest(behavior=behavior):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
campaign_file.write_text("{}", encoding="utf-8")
|
||||||
|
config = self._no_attachment_config(behavior=behavior, configure_missing_rule=True)
|
||||||
|
|
||||||
|
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||||
|
if behavior == "block":
|
||||||
|
self.assertFalse(validation.ok)
|
||||||
|
else:
|
||||||
|
self.assertTrue(validation.ok)
|
||||||
|
|
||||||
|
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||||
|
self.assertEqual(result.report.queueable_count, queueable_count)
|
||||||
|
message = result.report.messages[0]
|
||||||
|
self.assertEqual(message.build_status.value, build_status)
|
||||||
|
self.assertEqual(message.validation_status.value, validation_status)
|
||||||
|
coverage_issues = [issue for issue in message.issues if issue.code == "missing_attachment_coverage"]
|
||||||
|
if issue_behavior is None:
|
||||||
|
self.assertEqual(coverage_issues, [])
|
||||||
|
else:
|
||||||
|
self.assertEqual(coverage_issues[0].behavior, issue_behavior)
|
||||||
|
self.assertEqual(result.built_messages[0].mime is not None, has_mime)
|
||||||
|
|
||||||
|
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
documents = root / "documents"
|
||||||
|
documents.mkdir()
|
||||||
|
(documents / "matched.xlsx").write_bytes(b"matched workbook")
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
campaign_file.write_text("{}", encoding="utf-8")
|
||||||
|
config = CampaignConfig.model_validate({
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "zip-missing-pattern", "name": "ZIP missing pattern", "mode": "test"},
|
||||||
|
"fields": [],
|
||||||
|
"global_values": {},
|
||||||
|
"server": {"smtp": {"host": "smtp.example.invalid", "port": 587, "security": "starttls"}},
|
||||||
|
"recipients": {"from": {"email": "sender@example.org", "type": "to"}, "allow_individual_to": True},
|
||||||
|
"template": {"subject": "Subject", "text": "Body"},
|
||||||
|
"attachments": {
|
||||||
|
"base_path": ".",
|
||||||
|
"allow_individual": True,
|
||||||
|
"send_without_attachments": False,
|
||||||
|
"zip": {
|
||||||
|
"enabled": True,
|
||||||
|
"archives": [{"id": "bundle", "name": "recipient-bundle.zip", "standard": True}],
|
||||||
|
},
|
||||||
|
"global": [],
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"inline": [{
|
||||||
|
"id": "recipient-1",
|
||||||
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||||
|
"attachments": [
|
||||||
|
{"id": "matched", "base_dir": "documents", "file_filter": "matched.xlsx", "required": True},
|
||||||
|
{
|
||||||
|
"id": "missing",
|
||||||
|
"base_dir": "documents",
|
||||||
|
"file_filter": "missing.xlsx",
|
||||||
|
"required": False,
|
||||||
|
"missing_behavior": "warn",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"validation_policy": {
|
||||||
|
"missing_email": "block",
|
||||||
|
"template_error": "block",
|
||||||
|
"missing_optional_attachment": "warn",
|
||||||
|
},
|
||||||
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||||
|
})
|
||||||
|
|
||||||
|
validation = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||||
|
self.assertTrue(validation.ok)
|
||||||
|
self.assertIn("missing_optional_attachment", {issue.code for issue in validation.issues})
|
||||||
|
|
||||||
|
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=False)
|
||||||
|
self.assertEqual(result.report.built_count, 1)
|
||||||
|
self.assertEqual(result.report.queueable_count, 1)
|
||||||
|
message = result.report.messages[0]
|
||||||
|
self.assertEqual(message.attachment_count, 1)
|
||||||
|
summaries = {attachment.attachment_id: attachment for attachment in message.attachments}
|
||||||
|
self.assertEqual(summaries["matched"].zip_filename, "recipient-bundle.zip")
|
||||||
|
self.assertEqual(summaries["matched"].zip_entry_names, ["matched.xlsx"])
|
||||||
|
self.assertIsNone(summaries["missing"].zip_filename)
|
||||||
|
self.assertEqual(summaries["missing"].zip_entry_names, [])
|
||||||
|
self.assertEqual(summaries["missing"].matches, [])
|
||||||
|
|
||||||
|
mime = result.built_messages[0].mime
|
||||||
|
self.assertIsNotNone(mime)
|
||||||
|
attachments = {part.get_filename(): part.get_payload(decode=True) for part in mime.iter_attachments()}
|
||||||
|
self.assertEqual(set(attachments), {"recipient-bundle.zip"})
|
||||||
|
with zipfile.ZipFile(io.BytesIO(attachments["recipient-bundle.zip"])) as archive:
|
||||||
|
self.assertEqual(archive.namelist(), ["matched.xlsx"])
|
||||||
|
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
151
tests/test_partial_validation.py
Normal file
151
tests/test_partial_validation.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_campaign.backend import router
|
||||||
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||||
|
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||||
|
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignPartialValidationTests(unittest.TestCase):
|
||||||
|
def test_partial_validation_reports_counts_across_sections(self) -> None:
|
||||||
|
result = validate_campaign_partial({
|
||||||
|
"campaign": {},
|
||||||
|
"recipients": {"from": {}},
|
||||||
|
"entries": {"source": {}, "mapping": {}},
|
||||||
|
"template": {"body_mode": "both"},
|
||||||
|
"attachments": {},
|
||||||
|
"delivery": {"rate_limit": {"messages_per_minute": 0}},
|
||||||
|
})
|
||||||
|
|
||||||
|
codes = {item["code"] for item in result["issues"]}
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertEqual(3, result["error_count"])
|
||||||
|
self.assertIn("missing_campaign_id", codes)
|
||||||
|
self.assertIn("missing_email_mapping", codes)
|
||||||
|
self.assertIn("missing_template_text_body", codes)
|
||||||
|
self.assertIn("missing_attachment_base_path", codes)
|
||||||
|
self.assertIn("invalid_rate_limit", codes)
|
||||||
|
|
||||||
|
def test_partial_validation_filters_to_requested_section(self) -> None:
|
||||||
|
result = validate_campaign_partial(
|
||||||
|
{
|
||||||
|
"campaign": {},
|
||||||
|
"template": {"body_mode": "text"},
|
||||||
|
"delivery": {"rate_limit": {"messages_per_minute": "fast"}},
|
||||||
|
},
|
||||||
|
section="template",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(result["ok"])
|
||||||
|
self.assertEqual("template", result["section"])
|
||||||
|
self.assertEqual(0, result["error_count"])
|
||||||
|
self.assertEqual({"missing_subject", "missing_template_text_body"}, {item["code"] for item in result["issues"]})
|
||||||
|
|
||||||
|
def test_address_lookup_returns_unavailable_when_addresses_capability_absent(self) -> None:
|
||||||
|
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(router, "_registry_capability", return_value=None):
|
||||||
|
response = router.lookup_campaign_addresses("campaign-1", query="", session=object(), principal=object())
|
||||||
|
|
||||||
|
self.assertFalse(response.available)
|
||||||
|
self.assertEqual(response.candidates, [])
|
||||||
|
|
||||||
|
def test_address_lookup_accepts_empty_query_for_suggestion_preload(self) -> None:
|
||||||
|
class LookupCapability:
|
||||||
|
def lookup(self, _session, _principal, *, query: str, limit: int):
|
||||||
|
self.query = query
|
||||||
|
self.limit = limit
|
||||||
|
return [{
|
||||||
|
"contact_id": "contact-1",
|
||||||
|
"address_book_id": "book-1",
|
||||||
|
"display_name": "Ada Lovelace",
|
||||||
|
"email": "ada@example.local",
|
||||||
|
"email_label": "work",
|
||||||
|
"tags": [],
|
||||||
|
"source_kind": "local",
|
||||||
|
"provenance": {"module": "addresses"},
|
||||||
|
}]
|
||||||
|
|
||||||
|
capability = LookupCapability()
|
||||||
|
with patch.object(router, "_get_campaign_for_principal", return_value=None), patch.object(router, "_registry_capability", return_value=capability):
|
||||||
|
response = router.lookup_campaign_addresses("campaign-1", query="", limit=100, session=object(), principal=object())
|
||||||
|
|
||||||
|
self.assertEqual(capability.query, "")
|
||||||
|
self.assertEqual(capability.limit, 100)
|
||||||
|
self.assertTrue(response.available)
|
||||||
|
self.assertEqual(response.candidates[0].email, "ada@example.local")
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignSemanticValidationTests(unittest.TestCase):
|
||||||
|
def test_semantic_validation_reports_zip_delivery_and_external_mapping_issues(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
(root / "recipients.csv").write_text("email\nada@example.local\n", encoding="utf-8")
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
config = CampaignConfig.model_validate({
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
|
||||||
|
"fields": [
|
||||||
|
{"name": "pin", "type": "string", "can_override": False},
|
||||||
|
{"name": "secret", "type": "password"},
|
||||||
|
],
|
||||||
|
"global_values": {"unknown": "value"},
|
||||||
|
"recipients": {
|
||||||
|
"from": [{"email": "sender@example.local"}],
|
||||||
|
"to": [{"email": "recipient@example.local"}],
|
||||||
|
},
|
||||||
|
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
|
||||||
|
"attachments": {
|
||||||
|
"base_path": "attachments",
|
||||||
|
"zip": {
|
||||||
|
"enabled": True,
|
||||||
|
"archives": [
|
||||||
|
{
|
||||||
|
"id": "main",
|
||||||
|
"name": "bundle.zip",
|
||||||
|
"standard": True,
|
||||||
|
"password_enabled": True,
|
||||||
|
"password_field": "secret",
|
||||||
|
"password_scope": "global",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"source": {"type": "csv", "path": "recipients.csv", "has_header": True},
|
||||||
|
"mapping": {"fields.pin": "missing_pin", "fields.unknown": "missing_unknown"},
|
||||||
|
"defaults": {
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"base_dir": "",
|
||||||
|
"file_filter": "*.pdf",
|
||||||
|
"zip": {"archive_id": "missing-archive"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"delivery": {"imap_append_sent": {"enabled": True}},
|
||||||
|
})
|
||||||
|
|
||||||
|
report = validate_campaign_config(config, campaign_file=campaign_file, check_files=True)
|
||||||
|
|
||||||
|
codes = {issue.code for issue in report.issues}
|
||||||
|
self.assertFalse(report.ok)
|
||||||
|
self.assertEqual("external:csv", report.entries_mode)
|
||||||
|
self.assertIsNone(report.entries_count)
|
||||||
|
self.assertIn("unknown_global_value", codes)
|
||||||
|
self.assertIn("zip_global_password_value_missing", codes)
|
||||||
|
self.assertIn("zip_archive_unknown", codes)
|
||||||
|
self.assertIn("delivery_imap_enabled_without_server_imap", codes)
|
||||||
|
self.assertIn("missing_smtp_config", codes)
|
||||||
|
self.assertIn("unknown_mapping_target", codes)
|
||||||
|
self.assertIn("mapping_target_not_overridable", codes)
|
||||||
|
self.assertIn("mapping_columns_missing", codes)
|
||||||
|
self.assertIn("attachments_base_path_not_found", codes)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
68
tests/test_zip_service.py
Normal file
68
tests/test_zip_service.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyzipper
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
pyzipper = None
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||||
|
|
||||||
|
|
||||||
|
class ZipServiceTests(unittest.TestCase):
|
||||||
|
def test_standard_password_zip_uses_zipcrypto_readable_by_stdlib(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
source = root / "source.txt"
|
||||||
|
source.write_text("Hello Windows ZIP", encoding="utf-8")
|
||||||
|
output = root / "standard.zip"
|
||||||
|
|
||||||
|
create_zip_archive(output, [(source, "message.txt")], "secret", "zip_standard")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output) as archive:
|
||||||
|
info = archive.getinfo("message.txt")
|
||||||
|
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
|
||||||
|
self.assertTrue(info.flag_bits & 0x1)
|
||||||
|
self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP")
|
||||||
|
|
||||||
|
@unittest.skipIf(pyzipper is None, "pyzipper is not installed")
|
||||||
|
def test_aes_password_zip_keeps_aes_encryption(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
source = root / "source.txt"
|
||||||
|
source.write_text("Hello AES ZIP", encoding="utf-8")
|
||||||
|
output = root / "aes.zip"
|
||||||
|
|
||||||
|
create_zip_archive(output, [(source, "message.txt")], "secret", "aes")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output) as archive:
|
||||||
|
info = archive.getinfo("message.txt")
|
||||||
|
self.assertEqual(info.compress_type, 99)
|
||||||
|
self.assertTrue(info.flag_bits & 0x1)
|
||||||
|
|
||||||
|
with pyzipper.AESZipFile(output) as archive:
|
||||||
|
archive.setpassword(b"secret")
|
||||||
|
self.assertEqual(archive.read("message.txt"), b"Hello AES ZIP")
|
||||||
|
|
||||||
|
def test_unprotected_zip_ignores_encryption_mode(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
source = root / "source.txt"
|
||||||
|
source.write_text("Plain ZIP", encoding="utf-8")
|
||||||
|
output = root / "plain.zip"
|
||||||
|
|
||||||
|
create_zip_archive(output, [(source, "message.txt")], "", "zip_standard")
|
||||||
|
|
||||||
|
with zipfile.ZipFile(output) as archive:
|
||||||
|
info = archive.getinfo("message.txt")
|
||||||
|
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
|
||||||
|
self.assertFalse(info.flag_bits & 0x1)
|
||||||
|
self.assertEqual(archive.read("message.txt"), b"Plain ZIP")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -26,7 +26,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
||||||
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
|
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
|
||||||
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js"
|
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
|
||||||
|
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|||||||
@@ -162,6 +162,59 @@ export type RecipientImportMappingProfileListResponse = {
|
|||||||
profiles: RecipientImportMappingProfile[];
|
profiles: RecipientImportMappingProfile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignAddressLookupCandidate = {
|
||||||
|
contact_id: string;
|
||||||
|
address_book_id: string;
|
||||||
|
display_name: string;
|
||||||
|
email?: string | null;
|
||||||
|
email_label?: string | null;
|
||||||
|
organization?: string | null;
|
||||||
|
role_title?: string | null;
|
||||||
|
tags: string[];
|
||||||
|
source_kind: string;
|
||||||
|
source_ref?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignAddressLookupResponse = {
|
||||||
|
available: boolean;
|
||||||
|
candidates: CampaignAddressLookupCandidate[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignRecipientAddressSource = {
|
||||||
|
source_id: string;
|
||||||
|
source_label: string;
|
||||||
|
source_kind: string;
|
||||||
|
source_revision: string;
|
||||||
|
recipient_count: number;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignRecipientAddressSourcesResponse = {
|
||||||
|
available: boolean;
|
||||||
|
sources: CampaignRecipientAddressSource[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignRecipientSnapshotItem = {
|
||||||
|
contact_id: string;
|
||||||
|
display_name: string;
|
||||||
|
email: string;
|
||||||
|
email_label?: string | null;
|
||||||
|
fields: Record<string, unknown>;
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignRecipientAddressSourceSnapshot = {
|
||||||
|
source_id: string;
|
||||||
|
source_label: string;
|
||||||
|
source_kind: string;
|
||||||
|
source_revision: string;
|
||||||
|
generated_at: string;
|
||||||
|
recipients: CampaignRecipientSnapshotItem[];
|
||||||
|
provenance: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignVersionUpdatePayload = {
|
export type CampaignVersionUpdatePayload = {
|
||||||
campaign_json?: Record<string, unknown> | null;
|
campaign_json?: Record<string, unknown> | null;
|
||||||
current_flow?: string | null;
|
current_flow?: string | null;
|
||||||
@@ -260,6 +313,8 @@ export type CampaignAttachmentPreviewFile = {
|
|||||||
checksum_sha256?: string;
|
checksum_sha256?: string;
|
||||||
size_bytes?: number;
|
size_bytes?: number;
|
||||||
content_type?: string | null;
|
content_type?: string | null;
|
||||||
|
linked_to_campaign?: boolean;
|
||||||
|
share?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignAttachmentPreviewRule = {
|
export type CampaignAttachmentPreviewRule = {
|
||||||
@@ -281,6 +336,8 @@ export type CampaignAttachmentPreviewRule = {
|
|||||||
zip_filename?: string | null;
|
zip_filename?: string | null;
|
||||||
matches: CampaignAttachmentPreviewFile[];
|
matches: CampaignAttachmentPreviewFile[];
|
||||||
match_count: number;
|
match_count: number;
|
||||||
|
linked_match_count?: number;
|
||||||
|
unlinked_match_count?: number;
|
||||||
issues: Record<string, unknown>[];
|
issues: Record<string, unknown>[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -288,15 +345,37 @@ export type CampaignAttachmentPreviewResponse = {
|
|||||||
campaign_id: string;
|
campaign_id: string;
|
||||||
version_id: string;
|
version_id: string;
|
||||||
shared_file_count: number;
|
shared_file_count: number;
|
||||||
|
candidate_file_count?: number;
|
||||||
|
matched_file_count?: number;
|
||||||
|
linked_file_count?: number;
|
||||||
|
unlinked_file_count?: number;
|
||||||
rules: CampaignAttachmentPreviewRule[];
|
rules: CampaignAttachmentPreviewRule[];
|
||||||
|
linkable_files?: CampaignAttachmentPreviewFile[];
|
||||||
unused_shared_files: CampaignAttachmentPreviewFile[];
|
unused_shared_files: CampaignAttachmentPreviewFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignAttachmentPreviewPayload = {
|
export type CampaignAttachmentPreviewPayload = {
|
||||||
include_unmatched?: boolean;
|
include_unmatched?: boolean;
|
||||||
|
include_unlinked_candidates?: boolean;
|
||||||
campaign_json?: Record<string, unknown>;
|
campaign_json?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignAttachmentLinkMatchesPayload = {
|
||||||
|
campaign_json?: Record<string, unknown> | null;
|
||||||
|
dry_run?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignAttachmentLinkMatchesResponse = {
|
||||||
|
campaign_id: string;
|
||||||
|
version_id: string;
|
||||||
|
matched_file_count: number;
|
||||||
|
already_linked_file_count: number;
|
||||||
|
linked_file_count: number;
|
||||||
|
dry_run?: boolean;
|
||||||
|
linked_files: CampaignAttachmentPreviewFile[];
|
||||||
|
linkable_files: CampaignAttachmentPreviewFile[];
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignMockSendPayload = {
|
export type CampaignMockSendPayload = {
|
||||||
version_id?: string | null;
|
version_id?: string | null;
|
||||||
send?: boolean;
|
send?: boolean;
|
||||||
@@ -312,6 +391,13 @@ export type CampaignReviewStatePayload = {
|
|||||||
reviewed_message_keys: string[];
|
reviewed_message_keys: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignSendJobPayload = {
|
||||||
|
include_warnings?: boolean;
|
||||||
|
dry_run?: boolean;
|
||||||
|
use_rate_limit?: boolean;
|
||||||
|
enqueue_imap_task?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
export type CampaignJobsQuery = {
|
export type CampaignJobsQuery = {
|
||||||
versionId?: string;
|
versionId?: string;
|
||||||
@@ -419,6 +505,34 @@ export async function deleteRecipientImportMappingProfile(settings: ApiSettings,
|
|||||||
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
|
await apiFetch<void>(settings, `/api/v1/campaigns/recipient-import/mapping-profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function lookupCampaignAddresses(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
query: string,
|
||||||
|
limit = 25)
|
||||||
|
: Promise<CampaignAddressLookupResponse> {
|
||||||
|
const params = new URLSearchParams({ query, limit: String(limit) });
|
||||||
|
return apiFetch<CampaignAddressLookupResponse>(settings, `/api/v1/campaigns/${campaignId}/address-lookup?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCampaignRecipientAddressSources(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string)
|
||||||
|
: Promise<CampaignRecipientAddressSourcesResponse> {
|
||||||
|
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function snapshotCampaignRecipientAddressSource(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
sourceId: string)
|
||||||
|
: Promise<CampaignRecipientAddressSourceSnapshot> {
|
||||||
|
return apiFetch<CampaignRecipientAddressSourceSnapshot>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources/snapshot`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ source_id: sourceId })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
|
export async function getCampaign(settings: ApiSettings, campaignId: string): Promise<CampaignListItem> {
|
||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
||||||
}
|
}
|
||||||
@@ -621,11 +735,12 @@ versionId: string)
|
|||||||
export async function validateVersion(
|
export async function validateVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
checkFiles = false)
|
checkFiles = false,
|
||||||
|
linkUnsharedMatches = false)
|
||||||
: Promise<Record<string, unknown>> {
|
: Promise<Record<string, unknown>> {
|
||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/validate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ check_files: checkFiles })
|
body: JSON.stringify({ check_files: checkFiles, link_unshared_matches: linkUnsharedMatches })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,6 +769,19 @@ payload: CampaignAttachmentPreviewPayload = {})
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function linkCampaignAttachmentMatches(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
versionId: string,
|
||||||
|
payload: CampaignAttachmentLinkMatchesPayload = {})
|
||||||
|
: Promise<CampaignAttachmentLinkMatchesResponse> {
|
||||||
|
return apiFetch<CampaignAttachmentLinkMatchesResponse>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/link-matches`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCampaignSummary(
|
export async function getCampaignSummary(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
@@ -765,6 +893,18 @@ payload: Record<string, unknown> = {})
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendCampaignJob(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
jobId: string,
|
||||||
|
payload: CampaignSendJobPayload = {})
|
||||||
|
: Promise<Record<string, unknown>> {
|
||||||
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/send`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveCampaignJobOutcome(
|
export async function resolveCampaignJobOutcome(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";
|
export { apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "@govoplan/core-webui";
|
||||||
|
|||||||
@@ -1,142 +1,55 @@
|
|||||||
import type { ApiSettings } from "../types";
|
import type {
|
||||||
import { apiFetch } from "./client";
|
ApiSettings,
|
||||||
|
MailConnectionTestResponse,
|
||||||
|
MailImapFolderListResponse,
|
||||||
|
MailImapTestPayload,
|
||||||
|
MailProfilePolicy,
|
||||||
|
MailProfilePolicyResponse,
|
||||||
|
MailProfileScope,
|
||||||
|
MailSecurity,
|
||||||
|
MailServerProfile,
|
||||||
|
MailServerProfilePayload,
|
||||||
|
MailSmtpTestPayload,
|
||||||
|
MockMailboxMessageResponse
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
|
||||||
|
|
||||||
export type MailSecurity = "plain" | "tls" | "starttls";
|
const profileActionEndpoints = {
|
||||||
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
|
smtp: "test-smtp",
|
||||||
|
imap: "test-imap",
|
||||||
|
folders: "list-imap-folders"
|
||||||
|
} as const;
|
||||||
|
|
||||||
export type MailSmtpTestPayload = {
|
const rawSettingsEndpoints = {
|
||||||
host?: string | null;
|
smtp: "/api/v1/mail/test-smtp",
|
||||||
port?: number | null;
|
imap: "/api/v1/mail/test-imap",
|
||||||
username?: string | null;
|
folders: "/api/v1/mail/list-imap-folders"
|
||||||
password?: string | null;
|
} as const;
|
||||||
security?: MailSecurity;
|
|
||||||
timeout_seconds?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailImapTestPayload = MailSmtpTestPayload & {
|
function runProfileAction<TResponse>(
|
||||||
sent_folder?: string | null;
|
settings: ApiSettings,
|
||||||
};
|
profileId: string,
|
||||||
|
action: keyof typeof profileActionEndpoints
|
||||||
|
): Promise<TResponse> {
|
||||||
|
return apiPost<TResponse>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type MailTransportCredentialsPayload = {
|
function runRawSettingsAction<TResponse, TPayload>(
|
||||||
username?: string | null;
|
settings: ApiSettings,
|
||||||
password?: string | null;
|
payload: TPayload,
|
||||||
};
|
action: keyof typeof rawSettingsEndpoints
|
||||||
|
): Promise<TResponse> {
|
||||||
export type MailServerProfileCredentialsPayload = {
|
return apiPostJson<TResponse>(settings, rawSettingsEndpoints[action], payload);
|
||||||
smtp?: MailTransportCredentialsPayload;
|
}
|
||||||
imap?: MailTransportCredentialsPayload;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailConnectionTestResponse = {
|
|
||||||
ok: boolean;
|
|
||||||
protocol: "smtp" | "imap";
|
|
||||||
host?: string | null;
|
|
||||||
port?: number | null;
|
|
||||||
security?: MailSecurity | string | null;
|
|
||||||
message: string;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailImapFolderResponse = {
|
|
||||||
name: string;
|
|
||||||
flags?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailImapFolderListResponse = {
|
|
||||||
ok: boolean;
|
|
||||||
protocol: "imap";
|
|
||||||
host?: string | null;
|
|
||||||
port?: number | null;
|
|
||||||
security?: MailSecurity | string | null;
|
|
||||||
message: string;
|
|
||||||
folders: MailImapFolderResponse[];
|
|
||||||
detected_sent_folder?: string | null;
|
|
||||||
details?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailServerProfile = {
|
|
||||||
id: string;
|
|
||||||
tenant_id?: string | null;
|
|
||||||
scope_type: MailProfileScope;
|
|
||||||
scope_id?: string | null;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
description?: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
smtp: MailSmtpTestPayload;
|
|
||||||
imap?: MailImapTestPayload | null;
|
|
||||||
credentials?: MailServerProfileCredentialsPayload | null;
|
|
||||||
smtp_password_configured: boolean;
|
|
||||||
imap_password_configured: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
|
|
||||||
|
|
||||||
export const mailProfilePatternKeys = [
|
|
||||||
"smtp_hosts",
|
|
||||||
"imap_hosts",
|
|
||||||
"envelope_senders",
|
|
||||||
"from_headers",
|
|
||||||
"recipient_domains"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
|
|
||||||
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
|
|
||||||
|
|
||||||
export type MailCredentialPolicy = {
|
|
||||||
inherit?: boolean | null;
|
|
||||||
allow_override?: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailProfilePolicy = {
|
|
||||||
allowed_profile_ids?: string[] | null;
|
|
||||||
allow_user_profiles?: boolean | null;
|
|
||||||
allow_group_profiles?: boolean | null;
|
|
||||||
allow_campaign_profiles?: boolean | null;
|
|
||||||
smtp_credentials?: MailCredentialPolicy | null;
|
|
||||||
imap_credentials?: MailCredentialPolicy | null;
|
|
||||||
whitelist?: MailProfilePatternRules | null;
|
|
||||||
blacklist?: MailProfilePatternRules | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PolicySourceStep = {
|
|
||||||
scope_type: string;
|
|
||||||
scope_id?: string | null;
|
|
||||||
label: string;
|
|
||||||
applied_fields?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailProfilePolicyResponse = {
|
|
||||||
scope_type: MailProfileScope;
|
|
||||||
scope_id?: string | null;
|
|
||||||
policy: MailProfilePolicy;
|
|
||||||
effective_policy?: MailProfilePolicy | null;
|
|
||||||
parent_policy?: MailProfilePolicy | null;
|
|
||||||
effective_policy_sources?: PolicySourceStep[];
|
|
||||||
parent_policy_sources?: PolicySourceStep[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MailServerProfilePayload = {
|
|
||||||
name: string;
|
|
||||||
slug?: string | null;
|
|
||||||
description?: string | null;
|
|
||||||
is_active?: boolean;
|
|
||||||
scope_type?: MailProfileScope;
|
|
||||||
scope_id?: string | null;
|
|
||||||
smtp: MailSmtpTestPayload;
|
|
||||||
imap?: MailImapTestPayload | null;
|
|
||||||
credentials?: MailServerProfileCredentialsPayload | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
|
||||||
const params = new URLSearchParams();
|
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
|
||||||
if (includeInactive) params.set("include_inactive", "true");
|
include_inactive: includeInactive ? true : undefined,
|
||||||
if (campaignId) params.set("campaign_id", campaignId);
|
campaign_id: campaignId
|
||||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
});
|
||||||
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
|
|
||||||
return response.profiles ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
|
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
|
||||||
@@ -152,71 +65,40 @@ export async function getMailProfilePolicy(
|
|||||||
scopeId?: string | null,
|
scopeId?: string | null,
|
||||||
campaignId?: string | null
|
campaignId?: string | null
|
||||||
): Promise<MailProfilePolicyResponse> {
|
): Promise<MailProfilePolicyResponse> {
|
||||||
const params = new URLSearchParams();
|
return apiFetch<MailProfilePolicyResponse>(settings, apiPath(`/api/v1/mail/policies/${encodeURIComponent(scopeType)}`, {
|
||||||
if (scopeId) params.set("scope_id", scopeId);
|
scope_id: scopeId,
|
||||||
if (campaignId) params.set("campaign_id", campaignId);
|
campaign_id: campaignId
|
||||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
}));
|
||||||
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||||
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
|
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||||
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
|
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||||
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
|
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
|
export async function testSmtpSettings(settings: ApiSettings, payload: MailSmtpTestPayload): Promise<MailConnectionTestResponse> {
|
||||||
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
|
return runRawSettingsAction<MailConnectionTestResponse, MailSmtpTestPayload>(settings, payload, "smtp");
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
|
export async function testImapSettings(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailConnectionTestResponse> {
|
||||||
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
|
return runRawSettingsAction<MailConnectionTestResponse, MailImapTestPayload>(settings, payload, "imap");
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
|
export async function listImapFolders(settings: ApiSettings, payload: MailImapTestPayload): Promise<MailImapFolderListResponse> {
|
||||||
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
|
return runRawSettingsAction<MailImapFolderListResponse, MailImapTestPayload>(settings, payload, "folders");
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MockMailboxMessage = {
|
|
||||||
id: string;
|
|
||||||
kind: "smtp" | "imap_append" | string;
|
|
||||||
created_at: string;
|
|
||||||
envelope_from?: string | null;
|
|
||||||
envelope_recipients?: string[];
|
|
||||||
subject?: string | null;
|
|
||||||
from_header?: string | null;
|
|
||||||
to_header?: string | null;
|
|
||||||
cc_header?: string | null;
|
|
||||||
bcc_header?: string | null;
|
|
||||||
message_id?: string | null;
|
|
||||||
size_bytes?: number;
|
|
||||||
body_preview?: string | null;
|
|
||||||
attachment_count?: number;
|
|
||||||
folder?: string | null;
|
|
||||||
raw_eml?: string | null;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MockMailboxMessageResponse = {
|
|
||||||
message: MockMailboxMessage;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||||
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
|
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
|
||||||
|
export type { MailConnectionTestResponse, MailCredentialPolicy, MailImapFolderListResponse, MailImapFolderResponse, MailImapTestPayload, MailProfilePatternKey, MailProfilePatternRules, MailProfilePolicy, MailProfilePolicyLimitKey, MailProfilePolicyLimitPermissions, MailProfilePolicyResponse, MailProfileScope, MailSecurity, MailServerProfile, MailServerProfileCredentialsPayload, MailServerProfileListResponse, MailServerProfilePayload, MailSmtpTestPayload, MailTransportCredentialsPayload, MockMailboxMessage, MockMailboxMessageResponse } from "@govoplan/core-webui";
|
||||||
|
export type { MailPolicySourceStep as PolicySourceStep } from "@govoplan/core-webui";
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { Button } from "@govoplan/core-webui";
|
|
||||||
import { Card } from "@govoplan/core-webui";
|
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
|
||||||
|
|
||||||
const personalContacts = [
|
|
||||||
{ name: "i18n:govoplan-campaign.ada_lovelace.a69a9f8a", email: "ada@example.local", source: "Personal", tags: "i18n:govoplan-campaign.used_recently.af75cea7" },
|
|
||||||
{ name: "i18n:govoplan-campaign.grace_hopper.d97e6939", email: "grace@example.local", source: "Personal", tags: "i18n:govoplan-campaign.favorite.6b90b6a1" }];
|
|
||||||
|
|
||||||
|
|
||||||
const groupContacts = [
|
|
||||||
{ name: "i18n:govoplan-campaign.project_office.c35aa9ca", email: "project-office@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared.50d0d8dd" },
|
|
||||||
{ name: "i18n:govoplan-campaign.finance_team.ff353e5c", email: "finance@example.local", source: "Group", tags: "i18n:govoplan-campaign.shared_list.b3c94b39" }];
|
|
||||||
|
|
||||||
|
|
||||||
const tenantContacts = [
|
|
||||||
{ name: "i18n:govoplan-campaign.helpdesk.191815bf", email: "helpdesk@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" },
|
|
||||||
{ name: "i18n:govoplan-campaign.data_protection.06e87cd3", email: "privacy@example.local", source: "Tenant", tags: "i18n:govoplan-campaign.directory.4b892fe0" }];
|
|
||||||
|
|
||||||
|
|
||||||
function contactColumns(): DataGridColumn<{name: string;email: string;source: string;tags: string;}>[] {
|
|
||||||
return [
|
|
||||||
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (contact) => <strong>{contact.name}</strong>, value: (contact) => contact.name },
|
|
||||||
{ id: "email", header: "i18n:govoplan-campaign.email.84add5b2", width: 240, sortable: true, filterable: true, value: (contact) => contact.email },
|
|
||||||
{ id: "scope", header: "i18n:govoplan-campaign.scope.4651a34e", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "Personal", label: "i18n:govoplan-campaign.personal.40f07323" }, { value: "Group", label: "i18n:govoplan-campaign.group.171a0606" }, { value: "Tenant", label: "i18n:govoplan-campaign.tenant.3ca93c78" }] }, value: (contact) => contact.source },
|
|
||||||
{ id: "tags", header: "i18n:govoplan-campaign.tags.848eed0f", width: 180, sortable: true, filterable: true, value: (contact) => contact.tags },
|
|
||||||
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "mock", label: "i18n:govoplan-campaign.mock.3bba2a47" }], display: "pill" }, render: () => <StatusBadge status="mock" />, value: () => "mock" }];
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AddressBookPage() {
|
|
||||||
const allContacts = [...personalContacts, ...groupContacts, ...tenantContacts];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-pad workspace-data-page module-entry-page address-book-page">
|
|
||||||
<div className="page-heading split workspace-heading">
|
|
||||||
<div>
|
|
||||||
<PageTitle>i18n:govoplan-campaign.address_book.f6327f59</PageTitle>
|
|
||||||
<p>i18n:govoplan-campaign.mock_workspace_for_personal_group_and_tenant_add.ce99f4d4</p>
|
|
||||||
</div>
|
|
||||||
<div className="button-row compact-actions">
|
|
||||||
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
|
||||||
<Button variant="primary" disabled>i18n:govoplan-campaign.add_contact.6da0b4b8</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="metric-grid">
|
|
||||||
<Card title="i18n:govoplan-campaign.personal.40f07323"><strong className="module-big-number">{personalContacts.length}</strong><p className="muted">i18n:govoplan-campaign.private_contacts_and_remembered_addresses.2bd71556</p></Card>
|
|
||||||
<Card title="i18n:govoplan-campaign.group.171a0606"><strong className="module-big-number">{groupContacts.length}</strong><p className="muted">i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d</p></Card>
|
|
||||||
<Card title="i18n:govoplan-campaign.tenant.3ca93c78"><strong className="module-big-number">{tenantContacts.length}</strong><p className="muted">i18n:govoplan-campaign.tenant_directory_and_approved_shared_contacts.fa671f1b</p></Card>
|
|
||||||
<Card title="i18n:govoplan-campaign.sync.905f6309"><strong className="module-big-number">i18n:govoplan-campaign.mock.3bba2a47</strong><p className="muted">i18n:govoplan-campaign.carddav_ldap_import_connectors_can_be_added_late.0471f513</p></Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dashboard-grid settings-dashboard-grid">
|
|
||||||
<Card title="i18n:govoplan-campaign.address_book_scopes.b0d0efde">
|
|
||||||
<div className="address-book-scope-list">
|
|
||||||
<AddressBookScope title="i18n:govoplan-campaign.personal_address_book.e240066d" description="i18n:govoplan-campaign.private_contacts_remembered_recipients_and_perso.685cca95" status="Local" />
|
|
||||||
<AddressBookScope title="i18n:govoplan-campaign.group_address_books.aed5a7c0" description="i18n:govoplan-campaign.shared_contact_sets_for_teams_departments_or_cam.4408edd7" status="Shared" />
|
|
||||||
<AddressBookScope title="i18n:govoplan-campaign.tenant_directory.11b0e09c" description="i18n:govoplan-campaign.tenant_wide_contacts_functional_mailboxes_and_ap.c437a8b9" status="Directory" />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
<Card title="i18n:govoplan-campaign.planned_address_actions.1d4a056a">
|
|
||||||
<div className="placeholder-stack">
|
|
||||||
<span>i18n:govoplan-campaign.choose_addresses_from_personal_group_or_tenant_s.3d6dc0e4</span>
|
|
||||||
<span>i18n:govoplan-campaign.remember_addresses_used_in_campaigns_after_opt_i.6f8b2529</span>
|
|
||||||
<span>i18n:govoplan-campaign.share_selected_contacts_with_a_group.604d1464</span>
|
|
||||||
<span>i18n:govoplan-campaign.use_contacts_in_to_cc_bcc_sender_and_reply_to_fi.79f3ea6a</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.contacts.b0dd615c" actions={<Button disabled>i18n:govoplan-campaign.manage_sources.ec758de0</Button>}>
|
|
||||||
<DataGrid
|
|
||||||
id="address-book-contacts"
|
|
||||||
rows={allContacts}
|
|
||||||
columns={contactColumns()}
|
|
||||||
getRowKey={(contact) => contact.source + "-" + contact.email}
|
|
||||||
emptyText="i18n:govoplan-campaign.no_contacts_found.ad977b09"
|
|
||||||
className="compact-table-wrap module-table" />
|
|
||||||
|
|
||||||
</Card>
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddressBookScope({ title, description, status }: {title: string;description: string;status: string;}) {
|
|
||||||
return (
|
|
||||||
<div className="address-book-scope-card">
|
|
||||||
<div>
|
|
||||||
<strong>{title}</strong>
|
|
||||||
<p>{description}</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={status} />
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import { Button } from "@govoplan/core-webui";
|
|||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
@@ -40,7 +41,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -238,7 +239,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
|
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
|
||||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,6 +250,13 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
|
<div className="metric-grid attachment-statistics-grid">
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.upload_support.1c54931c" value={managedFilesAvailable ? "Files" : "Manual"} tone={managedFilesAvailable ? "good" : filesModuleInstalled ? "warning" : "neutral"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
||||||
<div className="admin-table-surface attachment-sources-table-surface">
|
<div className="admin-table-surface attachment-sources-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
@@ -300,7 +308,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
{zipArchiveNameValidation.message &&
|
{zipArchiveNameValidation.message &&
|
||||||
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
|
<DismissibleAlert tone="danger" compact resetKey={zipArchiveNameValidation.message} className="attachment-zip-name-error">{zipArchiveNameValidation.message}</DismissibleAlert>
|
||||||
}
|
}
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1</p>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
||||||
@@ -319,20 +326,6 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.statistics.2086b21f" collapsible>
|
|
||||||
<dl className="detail-list">
|
|
||||||
<div><dt>i18n:govoplan-campaign.base_paths.edf35a97</dt><dd>{basePaths.length}</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-campaign.global_attachments.438263a1</dt><dd>direct: {globalSummary.direct} i18n:govoplan-campaign.rules.e5d36074 {globalSummary.rules}</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-campaign.per_recipient_patterns.53da30da</dt><dd>{individualRulesCount}</dd></div>
|
|
||||||
<div><dt>i18n:govoplan-campaign.upload_support.1c54931c</dt><dd>{managedFilesAvailable ? "i18n:govoplan-campaign.connected_through_files.95007112" : "i18n:govoplan-campaign.manual_paths.8e20627a"}</dd></div>
|
|
||||||
</dl>
|
|
||||||
<p className="muted small-note">{managedFilesAvailable ?
|
|
||||||
"i18n:govoplan-campaign.files_are_managed_in_the_top_level_files_module_.4b370222" :
|
|
||||||
filesModuleInstalled ?
|
|
||||||
"i18n:govoplan-campaign.managed_file_browsing_is_unavailable_use_manual_.782867fe" :
|
|
||||||
"i18n:govoplan-campaign.the_files_module_is_not_installed_use_manual_pat.49abc725"}</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
@@ -450,6 +443,19 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
|
|||||||
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
|
render: (archive, index) => <ToggleSwitch label="i18n:govoplan-campaign.protected.28531336" checked={archive.password_enabled} disabled={disabled} onChange={(checked) => patchArchive(index, { password_enabled: checked })} />,
|
||||||
value: (archive) => archive.password_enabled ? "protected" : "none"
|
value: (archive) => archive.password_enabled ? "protected" : "none"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "method", header: "ZIP mode", width: 280, sortable: true, filterable: true,
|
||||||
|
columnType: "from-list", list: { options: [{ value: "aes", label: "AES" }, { value: "zip_standard", label: "Win-compatible" }] },
|
||||||
|
render: (archive, index) =>
|
||||||
|
<ToggleSwitch
|
||||||
|
label="Win-compatible"
|
||||||
|
checked={archive.method === "zip_standard"}
|
||||||
|
disabled={disabled}
|
||||||
|
help="Win-compatible ZIP uses the legacy ZipCrypto format so password-protected archives can be opened with Windows Explorer. Use AES when recipients can use 7-Zip, NanaZip, WinRAR, or another AES-capable ZIP tool."
|
||||||
|
onChange={(checked) => patchArchive(index, { method: checked ? "zip_standard" : "aes" })} />,
|
||||||
|
|
||||||
|
value: (archive) => archive.method
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
||||||
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
|
|||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void reload({ force: true })} disabled={loading}>Discard</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, setSaveState, localError, setLocalError, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -157,7 +157,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
|||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
|
|||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void reload({ force: true })} disabled={loading}>Discard</Button>
|
||||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
|
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
|
<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={load} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void load(null)} disabled={loading}>Discard</Button>
|
||||||
<Button variant="primary" onClick={create} disabled={creating}>
|
<Button variant="primary" onClick={create} disabled={creating}>
|
||||||
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -131,6 +131,19 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function discardOverview() {
|
||||||
|
if (campaign) {
|
||||||
|
setIdentity({
|
||||||
|
external_id: campaign.external_id ?? "",
|
||||||
|
name: campaign.name ?? "",
|
||||||
|
status: campaign.status ?? "",
|
||||||
|
description: campaign.description ?? ""
|
||||||
|
});
|
||||||
|
setIdentityDirty(false);
|
||||||
|
}
|
||||||
|
await reload({ force: true });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
@@ -139,8 +152,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
|||||||
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading || savingIdentity || lockBusy}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void discardOverview()} disabled={loading || savingIdentity || lockBusy}>Discard</Button>
|
||||||
<Link to="wizard/create"><Button>i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Button></Link>
|
|
||||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
|
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,13 +161,6 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
|||||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
|
||||||
<div className="metric-grid campaign-overview-metrics current-version-metrics">
|
|
||||||
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
|
||||||
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
|
||||||
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
|
|
||||||
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="metric-grid campaign-overview-metrics">
|
<div className="metric-grid campaign-overview-metrics">
|
||||||
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
|
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
|
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
|
||||||
@@ -163,23 +168,10 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
|||||||
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
|
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.current_version_state.f581778f" actions={<Link
|
<Card
|
||||||
to={`send?version=${campaign?.current_version_id}`}
|
title="i18n:govoplan-campaign.campaign_identity.a00ca574"
|
||||||
className={`btn btn-primary`}
|
collapsible
|
||||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
actions={<Link to="wizard/create"><Button>i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Button></Link>}>
|
||||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
|
||||||
|
|
||||||
i18n:govoplan-campaign.open.cf9b7706
|
|
||||||
</Link>}>
|
|
||||||
<div className="summary-grid overview-summary-grid">
|
|
||||||
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
|
||||||
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
|
||||||
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
|
||||||
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.campaign_identity.a00ca574" collapsible>
|
|
||||||
<div className="form-grid campaign-identity-grid">
|
<div className="form-grid campaign-identity-grid">
|
||||||
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
|
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
|
||||||
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
||||||
@@ -198,8 +190,27 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.version_history.91f86581" collapsible>
|
<Card title="Versions" collapsible actions={<Link
|
||||||
<div className="admin-table-surface">
|
to={`send?version=${campaign?.current_version_id}`}
|
||||||
|
className={`btn btn-primary`}
|
||||||
|
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||||
|
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||||
|
|
||||||
|
i18n:govoplan-campaign.open.cf9b7706
|
||||||
|
</Link>}>
|
||||||
|
<div className="metric-grid inside campaign-versions-metrics">
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
||||||
|
</div>
|
||||||
|
<div className="summary-grid overview-summary-grid">
|
||||||
|
<SummaryTile label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
||||||
|
<SummaryTile label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
||||||
|
<SummaryTile label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
||||||
|
<SummaryTile label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
|
||||||
|
</div>
|
||||||
|
<div className="admin-table-surface version-history-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-versions`}
|
id={`campaign-${campaignId}-versions`}
|
||||||
rows={versions}
|
rows={versions}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getCampaignJobsDelta,
|
getCampaignJobsDelta,
|
||||||
resolveCampaignJobOutcome,
|
resolveCampaignJobOutcome,
|
||||||
retryCampaignJobs,
|
retryCampaignJobs,
|
||||||
|
sendCampaignJob,
|
||||||
sendUnattemptedCampaignJobs,
|
sendUnattemptedCampaignJobs,
|
||||||
type CampaignJobDetailResponse,
|
type CampaignJobDetailResponse,
|
||||||
type CampaignJobsResponse } from
|
type CampaignJobsResponse } from
|
||||||
@@ -155,7 +156,11 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
}, [loadJobs]);
|
}, [loadJobs]);
|
||||||
|
|
||||||
async function reloadAll() {
|
async function reloadAll() {
|
||||||
await Promise.all([reload(), loadJobs()]);
|
resetDeltaWatermark(jobsQueryKey);
|
||||||
|
jobPageCursorsRef.current = { 1: null };
|
||||||
|
jobsRef.current = emptyCampaignJobsResponse();
|
||||||
|
setJobs(emptyCampaignJobsResponse());
|
||||||
|
await Promise.all([reload({ force: true }), loadJobs()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExplicitAction(action: "retry" | "unattempted") {
|
async function runExplicitAction(action: "retry" | "unattempted") {
|
||||||
@@ -177,6 +182,67 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const failedRowsOnPage = useMemo(
|
||||||
|
() => jobs.jobs.filter((row) => retryableFailedStatus(String(row.send_status ?? "")) && String(row.id ?? "")),
|
||||||
|
[jobs.jobs]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function retryFailedSynchronously(rows: Record<string, unknown>[]) {
|
||||||
|
if (!version || busyAction || rows.length === 0) return;
|
||||||
|
setBusyAction(rows.length === 1 ? `retry-sync:${String(rows[0].id ?? "")}` : "retry-sync-page");
|
||||||
|
setActionError("");
|
||||||
|
setActionMessage("");
|
||||||
|
let attempted = 0;
|
||||||
|
let accepted = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
const failures: string[] = [];
|
||||||
|
try {
|
||||||
|
for (const row of rows) {
|
||||||
|
const jobId = String(row.id ?? "");
|
||||||
|
if (!jobId) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sendStatus = String(row.send_status ?? "");
|
||||||
|
const queueResponse = await retryCampaignJobs(settings, campaignId, {
|
||||||
|
version_id: version.id,
|
||||||
|
job_ids: [jobId],
|
||||||
|
include_permanent: sendStatus === "failed_permanent",
|
||||||
|
enqueue_celery: false
|
||||||
|
});
|
||||||
|
const queueResult = asRecord(queueResponse.result ?? queueResponse);
|
||||||
|
if (Number(queueResult.selected_count ?? 0) < 1) {
|
||||||
|
skipped += 1;
|
||||||
|
const skippedRows = Array.isArray(queueResult.skipped) ? queueResult.skipped.map(asRecord) : [];
|
||||||
|
const reason = String(skippedRows[0]?.reason ?? "not selected for retry");
|
||||||
|
failures.push(`${shortJobId(jobId)}: ${reason}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attempted += 1;
|
||||||
|
try {
|
||||||
|
const sendResponse = await sendCampaignJob(settings, campaignId, jobId, {
|
||||||
|
include_warnings: true,
|
||||||
|
dry_run: false,
|
||||||
|
use_rate_limit: true,
|
||||||
|
enqueue_imap_task: false
|
||||||
|
});
|
||||||
|
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
|
||||||
|
const status = String(sendResult.status ?? "submitted");
|
||||||
|
if (status === "smtp_accepted" || status === "already_accepted") accepted += 1;
|
||||||
|
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
|
||||||
|
} catch (err) {
|
||||||
|
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const failed = failures.length;
|
||||||
|
setActionMessage(`Synchronous retry finished: ${attempted} attempted, ${accepted} accepted, ${failed} failed, ${skipped} skipped.`);
|
||||||
|
if (failures.length > 0) setActionError(failures.slice(0, 5).join("\n"));
|
||||||
|
await reloadAll();
|
||||||
|
} finally {
|
||||||
|
setBusyAction("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reconcileOutcome() {
|
async function reconcileOutcome() {
|
||||||
if (!reconcile || busyAction) return;
|
if (!reconcile || busyAction) return;
|
||||||
setBusyAction("reconcile");
|
setBusyAction("reconcile");
|
||||||
@@ -300,13 +366,14 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
return (
|
return (
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
|
||||||
|
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>}
|
||||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
|
||||||
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
[busyAction]);
|
[busyAction, retryFailedSynchronously]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
@@ -318,7 +385,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
||||||
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
||||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Discard</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
||||||
@@ -350,6 +417,9 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
<p className="muted">i18n:govoplan-campaign.these_actions_never_include_smtp_accepted_or_unr.449d0a80</p>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
|
<Button onClick={() => void runExplicitAction("retry")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.retry_temporary_failures.e65cfd13</Button>
|
||||||
|
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
||||||
|
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
|
||||||
|
</Button>
|
||||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -492,3 +562,11 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
|
|||||||
</section>);
|
</section>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retryableFailedStatus(status: string): boolean {
|
||||||
|
return status === "failed_temporary" || status === "failed_permanent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortJobId(jobId: string): string {
|
||||||
|
return jobId.length > 12 ? `${jobId.slice(0, 12)}...` : jobId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import CampaignOverviewPage from "./CampaignOverviewPage";
|
|||||||
import CampaignFieldsPage from "./CampaignFieldsPage";
|
import CampaignFieldsPage from "./CampaignFieldsPage";
|
||||||
import GlobalSettingsPage from "./GlobalSettingsPage";
|
import GlobalSettingsPage from "./GlobalSettingsPage";
|
||||||
import RecipientDataPage from "./RecipientDataPage";
|
import RecipientDataPage from "./RecipientDataPage";
|
||||||
import RecipientDetailsPage from "./RecipientDetailsPage";
|
|
||||||
import TemplateDataPage from "./TemplateDataPage";
|
import TemplateDataPage from "./TemplateDataPage";
|
||||||
import AttachmentsDataPage from "./AttachmentsDataPage";
|
import AttachmentsDataPage from "./AttachmentsDataPage";
|
||||||
import MailSettingsPage from "./MailSettingsPage";
|
import MailSettingsPage from "./MailSettingsPage";
|
||||||
@@ -26,7 +25,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
|||||||
policies: "policies",
|
policies: "policies",
|
||||||
fields: "fields",
|
fields: "fields",
|
||||||
recipients: "recipients",
|
recipients: "recipients",
|
||||||
"recipient-data": "recipient-data",
|
"recipient-data": "recipients",
|
||||||
template: "template",
|
template: "template",
|
||||||
files: "files",
|
files: "files",
|
||||||
"mail-settings": "mail-settings",
|
"mail-settings": "mail-settings",
|
||||||
@@ -85,7 +84,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
<Route path="data" element={<Navigate to="../recipients" replace />} />
|
<Route path="data" element={<Navigate to="../recipients" replace />} />
|
||||||
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="recipient-data" element={<RecipientDetailsPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="recipient-data" element={<Navigate to="../recipients" replace />} />
|
||||||
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
||||||
@@ -127,7 +126,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
|||||||
if (section === "policies" || section === "policy") return "policies";
|
if (section === "policies" || section === "policy") return "policies";
|
||||||
if (section === "fields") return "fields";
|
if (section === "fields") return "fields";
|
||||||
if (section === "recipients") return "recipients";
|
if (section === "recipients") return "recipients";
|
||||||
if (section === "recipient-data" || section === "recipient-details") return "recipient-data";
|
if (section === "recipient-data" || section === "recipient-details") return "recipients";
|
||||||
if (section === "template") return "template";
|
if (section === "template") return "template";
|
||||||
if (section === "files" || section === "attachments") return "files";
|
if (section === "files" || section === "attachments") return "files";
|
||||||
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
|
if (section === "mail-settings" || section === "server-settings" || section === "mail") return "mail-settings";
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -71,6 +71,11 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchSendWithoutAttachmentsBehavior(value: string) {
|
||||||
|
patch(["attachments", "send_without_attachments_behavior"], value);
|
||||||
|
patch(["attachments", "send_without_attachments"], value !== "block");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<div className="content-pad workspace-data-page">
|
||||||
<div className="page-heading split workspace-heading">
|
<div className="page-heading split workspace-heading">
|
||||||
@@ -79,7 +84,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -208,8 +213,8 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
effectiveClassName="campaign-policy-effective-note"
|
effectiveClassName="campaign-policy-effective-note"
|
||||||
label="i18n:govoplan-campaign.send_without_attachments.ead6d030"
|
label="i18n:govoplan-campaign.send_without_attachments.ead6d030"
|
||||||
note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc"
|
note="i18n:govoplan-campaign.campaign_wide_fallback_when_no_attachment_is_ava.84ffa0bc"
|
||||||
control={<ToggleSwitch label="i18n:govoplan-campaign.allowed.77c7b490" checked={getBool(attachments, "send_without_attachments", true)} disabled={locked} onChange={(checked) => patch(["attachments", "send_without_attachments"], checked)} />}
|
control={<PolicySelectControl value={sendWithoutAttachmentsBehavior(attachments)} disabled={locked} onChange={patchSendWithoutAttachmentsBehavior} />}
|
||||||
effective={getBool(attachments, "send_without_attachments", true) ? "i18n:govoplan-campaign.messages_may_be_sent_when_attachment_rules_allow.e6bf1aed" : "i18n:govoplan-campaign.missing_attachment_coverage_blocks_sending.05ff02a1"} />
|
effective={behaviorSummary(sendWithoutAttachmentsBehavior(attachments))} />
|
||||||
|
|
||||||
</PolicyTable>
|
</PolicyTable>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -261,3 +266,9 @@ function behaviorSummary(value: string): string {
|
|||||||
if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
|
if (value === "warn") return "i18n:govoplan-campaign.keeps_sending_possible_with_a_warning.66c84a54";
|
||||||
return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
|
return "i18n:govoplan-campaign.uses_the_configured_behavior.ea6e82a3";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendWithoutAttachmentsBehavior(attachments: Record<string, unknown>): string {
|
||||||
|
const configured = getText(attachments, "send_without_attachments_behavior");
|
||||||
|
if (behaviorOptions.includes(configured)) return configured;
|
||||||
|
return getBool(attachments, "send_without_attachments", true) ? "continue" : "block";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { MailServerSettingsPanel, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
import { MailServerFolderLookupResultView, MailServerSettingsPanel, ToggleSwitch, addressesFromValue, hasMailImapSettings, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTransportCredentialsPayloadFromRecords, usePlatformModuleInstalled, usePlatformUiCapability, type MailProfilesUiCapability, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
@@ -59,7 +59,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -98,6 +98,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
|
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || imapUnavailable || usingMailProfile && imapCredentialsInherited;
|
||||||
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
|
const imapDisabled = locked || inlineMailSettingsBlocked || imapUnavailable;
|
||||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||||
|
const imapAppendFolder = getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"));
|
||||||
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
|
const appendTargetFolderDisabled = imapDisabled || !imapAppendEnabled;
|
||||||
const inlinePolicyMessages = useMemo(() => {
|
const inlinePolicyMessages = useMemo(() => {
|
||||||
const validateMailPolicy = mailProfilesUi?.validateMailPolicy;
|
const validateMailPolicy = mailProfilesUi?.validateMailPolicy;
|
||||||
@@ -393,7 +394,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||||
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
{!isPolicyView && <Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,6 +465,33 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
</Card>
|
</Card>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{!isPolicyView &&
|
||||||
|
<Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
||||||
|
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||||
|
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8"
|
||||||
|
checked={imapAppendEnabled}
|
||||||
|
disabled={imapDisabled}
|
||||||
|
onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||||
|
</div>
|
||||||
|
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help="i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9">
|
||||||
|
<div className="field-with-action mail-server-folder-field">
|
||||||
|
<input
|
||||||
|
value={imapAppendFolder}
|
||||||
|
disabled={appendTargetFolderDisabled}
|
||||||
|
onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)}
|
||||||
|
placeholder="i18n:govoplan-core.auto.0d612c12" />
|
||||||
|
<Button type="button" variant="primary" onClick={() => void runFolderLookup()} disabled={appendTargetFolderDisabled || mailActionState === "folders"}>
|
||||||
|
{mailActionState === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : "i18n:govoplan-core.folders.c603ab65"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormField>
|
||||||
|
<MailServerFolderLookupResultView result={folderResult} disabled={appendTargetFolderDisabled} onUseDetected={useDetectedSentFolder} floatingFailures />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
|
||||||
{!isPolicyView &&
|
{!isPolicyView &&
|
||||||
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
|
<Card title="i18n:govoplan-campaign.mail_server_settings.6db620b0" collapsible>
|
||||||
{inlinePolicyMessages.length > 0 &&
|
{inlinePolicyMessages.length > 0 &&
|
||||||
@@ -489,25 +517,13 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
imapCredentialDisabled={imapCredentialDisabled}
|
imapCredentialDisabled={imapCredentialDisabled}
|
||||||
imapPasswordSaved={Boolean(usingMailProfile && imapCredentialsInherited && selectedProfile?.imap_password_configured)}
|
imapPasswordSaved={Boolean(usingMailProfile && imapCredentialsInherited && selectedProfile?.imap_password_configured)}
|
||||||
imapActionDisabled={imapDisabled || !mailModuleInstalled}
|
imapActionDisabled={imapDisabled || !mailModuleInstalled}
|
||||||
append={{
|
|
||||||
enabled: imapAppendEnabled,
|
|
||||||
folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")),
|
|
||||||
disabled: imapDisabled,
|
|
||||||
folderDisabled: appendTargetFolderDisabled,
|
|
||||||
onEnabledChange: (checked) => patch(["delivery", "imap_append_sent", "enabled"], checked),
|
|
||||||
onFolderChange: (folder) => patch(["delivery", "imap_append_sent", "folder"], folder)
|
|
||||||
}}
|
|
||||||
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
|
smtpTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_smtp.884a0e66" : "i18n:govoplan-campaign.test_smtp_login.a1359755"}
|
||||||
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
|
imapTestLabel={usingMailProfile ? "i18n:govoplan-campaign.test_profile_imap.e1cec0e0" : "i18n:govoplan-campaign.test_imap_login.c32e316e"}
|
||||||
busyAction={mailActionState}
|
busyAction={mailActionState}
|
||||||
onTestSmtp={runSmtpTest}
|
onTestSmtp={runSmtpTest}
|
||||||
onTestImap={runImapTest}
|
onTestImap={runImapTest}
|
||||||
onLookupFolders={runFolderLookup}
|
|
||||||
smtpTestResult={smtpTestResult}
|
smtpTestResult={smtpTestResult}
|
||||||
imapTestResult={imapTestResult}
|
imapTestResult={imapTestResult}
|
||||||
folderLookupResult={folderResult}
|
|
||||||
onUseDetectedFolder={useDetectedSentFolder}
|
|
||||||
useDetectedFolderDisabled={appendTargetFolderDisabled}
|
|
||||||
floatingResults />
|
floatingResults />
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,269 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
|
||||||
import type { ApiSettings } from "../../types";
|
|
||||||
import { Button } from "@govoplan/core-webui";
|
|
||||||
import { Card } from "@govoplan/core-webui";
|
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
|
||||||
import VersionLine from "./components/VersionLine";
|
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
|
||||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
|
||||||
import FieldValueInput from "./components/FieldValueInput";
|
|
||||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
|
||||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
|
||||||
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
|
||||||
import { importedRowsNeedAttachmentSource, materializeRecipientImport, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
|
|
||||||
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
|
||||||
import { RecipientImportDialog } from "./RecipientDataPage";
|
|
||||||
import { addressesFromValue } from "@govoplan/core-webui";
|
|
||||||
import { DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
|
||||||
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
|
||||||
|
|
||||||
|
|
||||||
export default function RecipientDetailsPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
|
||||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
|
||||||
const [recipientDataPage, setRecipientDataPage] = useState(1);
|
|
||||||
const [recipientDataPageSize, setRecipientDataPageSize] = useState(10);
|
|
||||||
|
|
||||||
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: "recipient-data",
|
|
||||||
unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_data_changes.c810507e",
|
|
||||||
unsavedMessage: "i18n:govoplan-campaign.recipient_field_values_or_attachments_have_unsav.49ab1870"
|
|
||||||
});
|
|
||||||
const entries = asRecord(displayDraft.entries);
|
|
||||||
const inlineEntries = asArray(entries.inline).map(asRecord);
|
|
||||||
const source = asRecord(entries.source);
|
|
||||||
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
|
|
||||||
const attachmentSection = asRecord(displayDraft.attachments);
|
|
||||||
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
|
|
||||||
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
|
|
||||||
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection, true), [attachmentSection]);
|
|
||||||
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
|
|
||||||
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
|
|
||||||
|
|
||||||
|
|
||||||
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
|
||||||
patch(["entries", "inline"], nextEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
|
|
||||||
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
|
|
||||||
replaceInlineEntries(nextEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEntryField(index: number, field: string, value: unknown) {
|
|
||||||
updateEntry(index, (entry) => ({
|
|
||||||
...entry,
|
|
||||||
fields: {
|
|
||||||
...asRecord(entry.fields),
|
|
||||||
[field]: value
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
|
|
||||||
updateEntry(index, (entry) => ({ ...entry, attachments }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
|
|
||||||
if (locked || !draft) return;
|
|
||||||
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
|
||||||
const currentAttachments = asRecord(draft.attachments);
|
|
||||||
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
|
|
||||||
let attachmentBasePath: AttachmentBasePath | null = null;
|
|
||||||
|
|
||||||
if (needsAttachmentSource) {
|
|
||||||
if (nextBasePaths.length === 0) {
|
|
||||||
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
|
|
||||||
}
|
|
||||||
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
|
|
||||||
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
|
|
||||||
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
|
|
||||||
const nextDraft = needsAttachmentSource ?
|
|
||||||
{
|
|
||||||
...importedDraft,
|
|
||||||
attachments: {
|
|
||||||
...asRecord(importedDraft.attachments),
|
|
||||||
base_paths: nextBasePaths,
|
|
||||||
base_path: nextBasePaths[0]?.path || "."
|
|
||||||
}
|
|
||||||
} :
|
|
||||||
importedDraft;
|
|
||||||
setDraft(nextDraft);
|
|
||||||
markDirty();
|
|
||||||
setImportOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content-pad workspace-data-page">
|
|
||||||
<div className="page-heading split workspace-heading">
|
|
||||||
<div>
|
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.recipient_data.c2baaf10</PageTitle>
|
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
|
||||||
</div>
|
|
||||||
<div className="button-row compact-actions">
|
|
||||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
|
||||||
<>
|
|
||||||
<Card title="i18n:govoplan-campaign.recipient_field_values_and_attachments.96e670ca" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
|
|
||||||
{inlineEntries.length === 0 && !source.type && <p className="muted">i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.e9f9a224</p>}
|
|
||||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
|
||||||
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.0d1ae252</DismissibleAlert>
|
|
||||||
}
|
|
||||||
{inlineEntries.length > 0 &&
|
|
||||||
<div className="admin-table-surface recipient-data-table-surface">
|
|
||||||
<DataGrid
|
|
||||||
id={`campaign-${campaignId}-recipient-data`}
|
|
||||||
rows={inlineEntries}
|
|
||||||
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
|
|
||||||
getRowKey={(entry, index) => String(entry.id || index)}
|
|
||||||
emptyText="i18n:govoplan-campaign.no_recipient_data_found.a12be7d0"
|
|
||||||
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
|
|
||||||
pagination={{
|
|
||||||
page: recipientDataPage,
|
|
||||||
pageSize: recipientDataPageSize,
|
|
||||||
pageSizeOptions: [10, 25, 50, 100, 250],
|
|
||||||
onPageChange: setRecipientDataPage,
|
|
||||||
onPageSizeChange: (pageSize) => {
|
|
||||||
setRecipientDataPageSize(pageSize);
|
|
||||||
setRecipientDataPage(1);
|
|
||||||
}
|
|
||||||
}} />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</Card>
|
|
||||||
</>
|
|
||||||
</LoadingFrame>
|
|
||||||
|
|
||||||
{importOpen &&
|
|
||||||
<RecipientImportDialog
|
|
||||||
settings={settings}
|
|
||||||
campaignId={campaignId}
|
|
||||||
existingEntries={inlineEntries}
|
|
||||||
existingFields={fieldDefinitions}
|
|
||||||
defaultAttachmentBasePath={defaultImportBasePath}
|
|
||||||
onCancel={() => setImportOpen(false)}
|
|
||||||
onImport={applyRecipientImport} />
|
|
||||||
|
|
||||||
}
|
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
type RecipientDataColumnContext = {
|
|
||||||
settings: ApiSettings;
|
|
||||||
campaignId: string;
|
|
||||||
draft: Record<string, unknown>;
|
|
||||||
locked: boolean;
|
|
||||||
filesModuleInstalled: boolean;
|
|
||||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
|
||||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
|
||||||
zipConfig: AttachmentZipCollection;
|
|
||||||
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
|
|
||||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
|
||||||
return [
|
|
||||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
|
||||||
{
|
|
||||||
id: "recipient",
|
|
||||||
header: "i18n:govoplan-campaign.recipient.90343260",
|
|
||||||
width: 260,
|
|
||||||
resizable: true,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
sticky: "start",
|
|
||||||
render: (entry) =>
|
|
||||||
<Link className="recipient-data-identity" to="../recipients" title="i18n:govoplan-campaign.open_recipient_address_profile.f7daa676">
|
|
||||||
<span className="recipient-data-address">{firstRecipientEmail(entry) || "i18n:govoplan-campaign.no_to_address.683350f9"}</span>
|
|
||||||
{extraRecipientCount(entry) > 0 && <span className="recipient-extra-bubble">+{extraRecipientCount(entry)}</span>}
|
|
||||||
</Link>,
|
|
||||||
|
|
||||||
value: firstRecipientEmail
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "attachments",
|
|
||||||
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
|
||||||
width: 180,
|
|
||||||
filterable: true,
|
|
||||||
render: (entry, index) => {
|
|
||||||
const attachments = normalizeAttachmentRules(entry.attachments);
|
|
||||||
return (
|
|
||||||
<AttachmentRulesOverlay
|
|
||||||
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
|
|
||||||
rules={attachments}
|
|
||||||
settings={settings}
|
|
||||||
campaignId={campaignId}
|
|
||||||
disabled={locked}
|
|
||||||
buttonLabel={`entries: ${attachments.length}`}
|
|
||||||
basePaths={individualAttachmentBasePaths}
|
|
||||||
zipConfig={zipConfig}
|
|
||||||
filesModuleInstalled={filesModuleInstalled}
|
|
||||||
previewContext={buildTemplatePreviewContext(draft, entry)}
|
|
||||||
onChange={(rules) => updateEntryAttachments(index, rules)} />);
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
|
|
||||||
},
|
|
||||||
...fieldDefinitions.map((field): DataGridColumn<Record<string, unknown>> => ({
|
|
||||||
id: `field-${field.name}`,
|
|
||||||
header: field.label || field.name,
|
|
||||||
width: 190,
|
|
||||||
resizable: true,
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
|
|
||||||
render: (entry, index) => {
|
|
||||||
const fields = asRecord(entry.fields);
|
|
||||||
return (
|
|
||||||
<FieldValueInput
|
|
||||||
className="recipient-field-input"
|
|
||||||
fieldType={field.type}
|
|
||||||
value={fields[field.name]}
|
|
||||||
disabled={locked || field.can_override === false}
|
|
||||||
placeholder={field.can_override === false ? "i18n:govoplan-campaign.uses_global_value.98eae5e0" : undefined}
|
|
||||||
onChange={(value) => updateEntryField(index, field.name, value)} />);
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
|
|
||||||
}))];
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstRecipientEmail(entry: Record<string, unknown>): string {
|
|
||||||
return (addressesFromValue(entry.to)[0] ?? addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0])?.email ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function extraRecipientCount(entry: Record<string, unknown>): number {
|
|
||||||
const count = addressesFromValue(entry.to).length;
|
|
||||||
return Math.max(0, count - 1);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
|
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -121,6 +121,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
const handle = window.setTimeout(() => {
|
const handle = window.setTimeout(() => {
|
||||||
void previewCampaignAttachments(settings, campaignId, version.id, {
|
void previewCampaignAttachments(settings, campaignId, version.id, {
|
||||||
include_unmatched: false,
|
include_unmatched: false,
|
||||||
|
include_unlinked_candidates: true,
|
||||||
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
|
campaign_json: campaignJsonForAttachmentPreview(displayDraft)
|
||||||
}).
|
}).
|
||||||
then((response) => {
|
then((response) => {
|
||||||
@@ -233,7 +234,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
||||||
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,45 +400,46 @@ draft: Record<string, unknown>)
|
|||||||
: CampaignMessagePreviewAttachment[] {
|
: CampaignMessagePreviewAttachment[] {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return [{
|
return [{
|
||||||
filename: i18nMessage("i18n:govoplan-campaign.resolving_attachment_patterns.87d7d21b"),
|
filename: "Resolving attachment patterns",
|
||||||
detail: i18nMessage("i18n:govoplan-campaign.managed_files_are_being_checked_for_this_recipie.489ecefd")
|
detail: "Managed files are being checked for this recipient preview."
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return [{ filename: i18nMessage("i18n:govoplan-campaign.attachment_preview_unavailable.62211756"), detail: error }];
|
return [{ filename: "Attachment preview unavailable", detail: error }];
|
||||||
}
|
}
|
||||||
return rules.flatMap((rule) => {
|
return rules.flatMap((rule) => {
|
||||||
const zipProtection = zipProtectionForRule(rule, draft);
|
const zipProtection = zipProtectionForRule(rule, draft);
|
||||||
const detailParts = [
|
const detailParts = [
|
||||||
rule.source === "global" ? i18nMessage("i18n:govoplan-campaign.global.5f1184f7") : i18nMessage("i18n:govoplan-campaign.recipient.90343260"),
|
rule.source === "global" ? "Global" : "Recipient",
|
||||||
rule.label,
|
rule.label,
|
||||||
rule.required ? i18nMessage("i18n:govoplan-campaign.required.eed6bfb4") : i18nMessage("i18n:govoplan-campaign.optional.0c6c4102"),
|
rule.required ? "Required" : "Optional",
|
||||||
rule.pattern].
|
rule.pattern].
|
||||||
filter(Boolean);
|
filter(Boolean);
|
||||||
const detail = detailParts.join(" · ");
|
const detail = detailParts.join(" · ");
|
||||||
const fallbackArchiveLabel = i18nMessage("i18n:govoplan-campaign.recipient_attachments_zip.79d436c7");
|
const fallbackArchiveLabel = "Recipient attachments ZIP";
|
||||||
if (rule.matches.length > 0) {
|
if (rule.matches.length > 0) {
|
||||||
return rule.matches.map((match) => ({
|
return rule.matches.map((match) => ({
|
||||||
filename: match.filename || match.display_path,
|
filename: match.filename || match.display_path,
|
||||||
label: rule.label,
|
label: rule.label,
|
||||||
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: match.display_path ? ` · ${match.display_path}` : "" }),
|
detail: `${match.linked_to_campaign === false ? "Unlinked candidate" : "Linked"} · ${match.display_path ? `${detail} · ${match.display_path}` : detail}`,
|
||||||
contentType: match.content_type,
|
contentType: match.content_type,
|
||||||
sizeBytes: match.size_bytes,
|
sizeBytes: match.size_bytes,
|
||||||
|
linkedToCampaign: match.linked_to_campaign !== false,
|
||||||
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
||||||
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
||||||
protected: zipProtection.protected,
|
protected: zipProtection.protected,
|
||||||
protectionNote: zipProtection.note
|
protectionNote: zipProtection.note
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
const pattern = rule.pattern || i18nMessage("i18n:govoplan-campaign.attachment_pattern.3540c952");
|
const pattern = rule.pattern || "attachment pattern";
|
||||||
return [{
|
return [{
|
||||||
filename: i18nMessage("i18n:govoplan-campaign.no_file_matched_value", { value0: pattern }),
|
filename: `No file matched ${pattern}`,
|
||||||
label: rule.label,
|
label: rule.label,
|
||||||
detail: i18nMessage("i18n:govoplan-campaign.value_value.a8618e4a", { value0: detail, value1: rule.status !== "ok" ? ` · ${rule.status}` : "" }),
|
detail: rule.status !== "ok" ? `${detail} · ${rule.status}` : detail,
|
||||||
archiveGroup: rule.zip_included ? rule.zip_filename || "recipient-attachments.zip" : null,
|
archiveGroup: null,
|
||||||
archiveLabel: rule.zip_included ? rule.zip_filename || fallbackArchiveLabel : null,
|
archiveLabel: null,
|
||||||
protected: zipProtection.protected,
|
protected: false,
|
||||||
protectionNote: zipProtection.note
|
protectionNote: null
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Button, DismissibleAlert, LoadingFrame, PageTitle } from "@govoplan/core-webui";
|
||||||
|
import type { ApiSettings } from "../../../types";
|
||||||
|
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||||
|
import LockedVersionNotice from "./LockedVersionNotice";
|
||||||
|
import VersionLine from "./VersionLine";
|
||||||
|
|
||||||
|
type CampaignDraftPageScaffoldProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
campaignId: string;
|
||||||
|
title: string;
|
||||||
|
loading: boolean;
|
||||||
|
version: CampaignVersionDetail | null;
|
||||||
|
versions: CampaignVersionListItem[];
|
||||||
|
saveState: string;
|
||||||
|
onReload: () => Promise<void>;
|
||||||
|
onSave: () => void;
|
||||||
|
dirty: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
draft: Record<string, unknown> | null;
|
||||||
|
error?: string | null;
|
||||||
|
localError?: string | null;
|
||||||
|
currentVersionId?: string | null;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CampaignDraftPageScaffold({
|
||||||
|
settings,
|
||||||
|
campaignId,
|
||||||
|
title,
|
||||||
|
loading,
|
||||||
|
version,
|
||||||
|
versions,
|
||||||
|
saveState,
|
||||||
|
onReload,
|
||||||
|
onSave,
|
||||||
|
dirty,
|
||||||
|
locked,
|
||||||
|
draft,
|
||||||
|
error,
|
||||||
|
localError,
|
||||||
|
currentVersionId,
|
||||||
|
children
|
||||||
|
}: CampaignDraftPageScaffoldProps) {
|
||||||
|
return (
|
||||||
|
<div className="content-pad workspace-data-page">
|
||||||
|
<div className="page-heading split workspace-heading">
|
||||||
|
<div>
|
||||||
|
<PageTitle loading={loading}>{title}</PageTitle>
|
||||||
|
<VersionLine version={version} versions={versions} status={saveState} />
|
||||||
|
</div>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button onClick={onReload} disabled={loading}>Discard</Button>
|
||||||
|
<Button variant="primary" onClick={onSave} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||||
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
|
||||||
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
|
<>{children}</>
|
||||||
|
</LoadingFrame>
|
||||||
|
</div>);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export type CampaignMessagePreviewOverlayProps = {
|
|||||||
raw?: string | null;
|
raw?: string | null;
|
||||||
rawLabel?: string;
|
rawLabel?: string;
|
||||||
navigation?: CampaignMessagePreviewNavigation;
|
navigation?: CampaignMessagePreviewNavigation;
|
||||||
|
actions?: ReactNode;
|
||||||
closeLabel?: string;
|
closeLabel?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -53,6 +54,7 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
raw,
|
raw,
|
||||||
rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
|
rawLabel = "i18n:govoplan-campaign.raw_mime.82c612d4",
|
||||||
navigation,
|
navigation,
|
||||||
|
actions,
|
||||||
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
||||||
onClose
|
onClose
|
||||||
}: CampaignMessagePreviewOverlayProps) {
|
}: CampaignMessagePreviewOverlayProps) {
|
||||||
@@ -88,11 +90,11 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
}, [navigation, onClose]);
|
}, [navigation, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
|
<div className="overlay-backdrop message-preview-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
|
||||||
<div className="modal-panel template-preview-modal message-preview-modal">
|
<div className="modal-panel template-preview-modal message-preview-modal">
|
||||||
<header className="modal-header">
|
<header className="modal-header">
|
||||||
<h2 id="message-preview-title">{title}</h2>
|
<h2 id="message-preview-title">{title}</h2>
|
||||||
<button className="modal-close" onClick={onClose}>×</button>
|
<button type="button" className="modal-close" aria-label={closeLabel} title={closeLabel} onClick={onClose}>×</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
{(recipientLabel || recipientNote || navigation) &&
|
{(recipientLabel || recipientNote || navigation) &&
|
||||||
@@ -131,7 +133,10 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>{closeLabel}</Button></footer>
|
<footer className="modal-footer">
|
||||||
|
{actions && <div className="button-row compact-actions">{actions}</div>}
|
||||||
|
<Button variant="primary" onClick={onClose}>{closeLabel}</Button>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type UseCampaignDraftEditorOptions = {
|
|||||||
campaignId: string;
|
campaignId: string;
|
||||||
version: CampaignVersionDetail | null;
|
version: CampaignVersionDetail | null;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
reload: () => Promise<void>;
|
reload: (options?: {force?: boolean;}) => Promise<void>;
|
||||||
setError: (message: string) => void;
|
setError: (message: string) => void;
|
||||||
currentStep: StepValue;
|
currentStep: StepValue;
|
||||||
currentFlow?: string;
|
currentFlow?: string;
|
||||||
@@ -124,12 +124,25 @@ export function useCampaignDraftEditor({
|
|||||||
}
|
}
|
||||||
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
|
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
|
||||||
|
|
||||||
|
const discardDraft = useCallback(async () => {
|
||||||
|
if (version) {
|
||||||
|
const initialDraft = ensureCampaignDraft(version);
|
||||||
|
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
|
||||||
|
setDraft(loadedDraft);
|
||||||
|
setDirty(false);
|
||||||
|
setLocalError("");
|
||||||
|
setSaveState(loadedLabelRef.current(version));
|
||||||
|
onLoadedRef.current?.(version, loadedDraft);
|
||||||
|
}
|
||||||
|
await reload({ force: true });
|
||||||
|
}, [reload, version]);
|
||||||
|
|
||||||
const unsavedRegistration = useMemo(() => dirty && !locked ? {
|
const unsavedRegistration = useMemo(() => dirty && !locked ? {
|
||||||
title: unsavedTitle,
|
title: unsavedTitle,
|
||||||
message: unsavedMessage,
|
message: unsavedMessage,
|
||||||
onSave: () => saveDraft("manual"),
|
onSave: () => saveDraft("manual"),
|
||||||
onDiscard: () => setDirty(false)
|
onDiscard: () => { void discardDraft(); }
|
||||||
} : null, [dirty, locked, saveDraft, unsavedMessage, unsavedTitle]);
|
} : null, [dirty, discardDraft, locked, saveDraft, unsavedMessage, unsavedTitle]);
|
||||||
|
|
||||||
useRegisterCampaignUnsavedChanges(unsavedRegistration);
|
useRegisterCampaignUnsavedChanges(unsavedRegistration);
|
||||||
|
|
||||||
@@ -145,6 +158,7 @@ export function useCampaignDraftEditor({
|
|||||||
setLocalError,
|
setLocalError,
|
||||||
patch,
|
patch,
|
||||||
markDirty,
|
markDirty,
|
||||||
|
discardDraft,
|
||||||
saveDraft
|
saveDraft
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -52,14 +52,16 @@ export function useCampaignWorkspaceData(
|
|||||||
[campaignId, selectedVersionId, includeCurrentVersion, includeSummary, includeVersions, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
[campaignId, selectedVersionId, includeCurrentVersion, includeSummary, includeVersions, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
||||||
);
|
);
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async (options?: {force?: boolean;}) => {
|
||||||
if (!campaignId) return;
|
if (!campaignId) return;
|
||||||
|
const force = options?.force === true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||||
let nextWatermark = getDeltaWatermark(queryKey);
|
if (force) resetDeltaWatermark(queryKey);
|
||||||
let merged: CampaignWorkspaceData = dataRef.current;
|
let nextWatermark = force ? null : getDeltaWatermark(queryKey);
|
||||||
|
let merged: CampaignWorkspaceData = force ? initialData : dataRef.current;
|
||||||
let hasMore = false;
|
let hasMore = false;
|
||||||
do {
|
do {
|
||||||
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
|
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
|
||||||
|
|||||||
53
webui/src/features/campaigns/utils/attachmentPreview.ts
Normal file
53
webui/src/features/campaigns/utils/attachmentPreview.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export type AttachmentPreviewFileLike = {
|
||||||
|
id: string;
|
||||||
|
display_path: string;
|
||||||
|
filename: string;
|
||||||
|
linked_to_campaign?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AttachmentPreviewLike<T extends AttachmentPreviewFileLike> = {
|
||||||
|
rules: Array<{matches: T[]}>;
|
||||||
|
linkable_files?: T[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function attachmentPreviewMatchedFiles<T extends AttachmentPreviewFileLike>(
|
||||||
|
preview: AttachmentPreviewLike<T> | null
|
||||||
|
): T[] {
|
||||||
|
if (!preview) return [];
|
||||||
|
const byId = new Map<string, T>();
|
||||||
|
for (const rule of preview.rules) {
|
||||||
|
for (const file of rule.matches) {
|
||||||
|
const key = file.id || file.display_path;
|
||||||
|
if (!key) continue;
|
||||||
|
const existing = byId.get(key);
|
||||||
|
if (existing) {
|
||||||
|
byId.set(key, {
|
||||||
|
...existing,
|
||||||
|
linked_to_campaign: existing.linked_to_campaign !== false || file.linked_to_campaign !== false
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
byId.set(key, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()].sort((left, right) => {
|
||||||
|
const linkOrder = Number(left.linked_to_campaign === false) - Number(right.linked_to_campaign === false);
|
||||||
|
if (linkOrder !== 0) return linkOrder;
|
||||||
|
return (left.display_path || left.filename).localeCompare(right.display_path || right.filename);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachmentPreviewLinkableFiles<T extends AttachmentPreviewFileLike>(
|
||||||
|
preview: AttachmentPreviewLike<T> | null
|
||||||
|
): T[] {
|
||||||
|
if (!preview) return [];
|
||||||
|
const source = preview.linkable_files && preview.linkable_files.length > 0
|
||||||
|
? preview.linkable_files
|
||||||
|
: attachmentPreviewMatchedFiles(preview).filter((file) => file.linked_to_campaign === false);
|
||||||
|
const byId = new Map<string, T>();
|
||||||
|
for (const file of source) {
|
||||||
|
const key = file.id || file.display_path;
|
||||||
|
if (key) byId.set(key, file);
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ type XlsxWorkbookSheet = {
|
|||||||
data: unknown[][];
|
data: unknown[][];
|
||||||
};
|
};
|
||||||
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
|
type XlsxWorkbookReader = (input: ArrayBuffer) => Promise<XlsxWorkbookSheet[]>;
|
||||||
type RawXlsxWorkbookReader = (input: unknown) => Promise<XlsxWorkbookSheet[]>;
|
|
||||||
|
|
||||||
export type ImportFieldDefinition = {
|
export type ImportFieldDefinition = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,7 +15,11 @@ export type ImportFieldDefinition = {
|
|||||||
|
|
||||||
export type ImportAttachmentBasePath = {
|
export type ImportAttachmentBasePath = {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
name?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
source?: string;
|
||||||
|
allow_individual?: boolean;
|
||||||
|
unsent_warning?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecipientImportMode = "append" | "replace";
|
export type RecipientImportMode = "append" | "replace";
|
||||||
@@ -29,7 +32,7 @@ export type CsvParseOptions = {
|
|||||||
quoted: boolean;
|
quoted: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RecipientImportSourceType = "csv" | "xlsx" | "text";
|
export type RecipientImportSourceType = "csv" | "xlsx" | "text" | "addresses";
|
||||||
|
|
||||||
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
|
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
|
||||||
|
|
||||||
@@ -99,6 +102,10 @@ export type RecipientImportProvenance = {
|
|||||||
imported_at: string;
|
imported_at: string;
|
||||||
mode: RecipientImportMode;
|
mode: RecipientImportMode;
|
||||||
source_type: RecipientImportSourceType;
|
source_type: RecipientImportSourceType;
|
||||||
|
source_id?: string | null;
|
||||||
|
source_label?: string | null;
|
||||||
|
source_revision?: string | null;
|
||||||
|
source_provenance?: Record<string, unknown>;
|
||||||
filename?: string | null;
|
filename?: string | null;
|
||||||
sheet_name?: string | null;
|
sheet_name?: string | null;
|
||||||
encoding?: string | null;
|
encoding?: string | null;
|
||||||
@@ -211,15 +218,6 @@ async function loadXlsxWorkbookReader(): Promise<XlsxWorkbookReader> {
|
|||||||
const module = await import("read-excel-file/browser");
|
const module = await import("read-excel-file/browser");
|
||||||
return module.default as XlsxWorkbookReader;
|
return module.default as XlsxWorkbookReader;
|
||||||
}
|
}
|
||||||
const globals = globalThis as typeof globalThis & {
|
|
||||||
Buffer?: { from(input: ArrayBuffer): unknown };
|
|
||||||
process?: { versions?: { node?: string } };
|
|
||||||
};
|
|
||||||
if (globals.process?.versions?.node && globals.Buffer?.from) {
|
|
||||||
const dynamicImport = new Function("specifier", "return import(specifier)") as (specifier: string) => Promise<{ default: RawXlsxWorkbookReader }>;
|
|
||||||
const module = await dynamicImport("read-excel-file/node");
|
|
||||||
return (input: ArrayBuffer) => module.default(globals.Buffer!.from(input));
|
|
||||||
}
|
|
||||||
const module = await import("read-excel-file/universal");
|
const module = await import("read-excel-file/universal");
|
||||||
return module.default as XlsxWorkbookReader;
|
return module.default as XlsxWorkbookReader;
|
||||||
}
|
}
|
||||||
@@ -463,6 +461,37 @@ options: MaterializeImportOptions)
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function materializeRecipientImportWithAttachmentDefaults(
|
||||||
|
draft: Record<string, unknown>,
|
||||||
|
preview: RecipientImportPreview,
|
||||||
|
options: MaterializeImportOptions)
|
||||||
|
: Record<string, unknown> {
|
||||||
|
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
|
||||||
|
const currentAttachments = asRecord(draft.attachments);
|
||||||
|
let nextBasePaths = normalizeImportAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
|
||||||
|
let attachmentBasePath: ImportAttachmentBasePath | null = null;
|
||||||
|
|
||||||
|
if (needsAttachmentSource) {
|
||||||
|
if (nextBasePaths.length === 0) {
|
||||||
|
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
|
||||||
|
}
|
||||||
|
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
|
||||||
|
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
|
||||||
|
attachmentBasePath = options.attachmentBasePath ?? nextBasePaths[selectedIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const importedDraft = materializeRecipientImport(draft, preview, { ...options, attachmentBasePath });
|
||||||
|
if (!needsAttachmentSource) return importedDraft;
|
||||||
|
return {
|
||||||
|
...importedDraft,
|
||||||
|
attachments: {
|
||||||
|
...asRecord(importedDraft.attachments),
|
||||||
|
base_paths: nextBasePaths,
|
||||||
|
base_path: nextBasePaths[0]?.path || "."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createRecipientImportProvenance(input: {
|
export function createRecipientImportProvenance(input: {
|
||||||
preview: RecipientImportPreview;
|
preview: RecipientImportPreview;
|
||||||
mappings: RecipientColumnMapping[];
|
mappings: RecipientColumnMapping[];
|
||||||
@@ -513,6 +542,27 @@ export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview
|
|||||||
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
|
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeImportAttachmentBasePaths(value: unknown, attachments: ImportRecord, fallbackAllowIndividual = false): ImportAttachmentBasePath[] {
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
return value.filter(isRecord).map((basePath, index) => ({
|
||||||
|
id: getText(basePath, "id", `base-path-${index + 1}`),
|
||||||
|
name: getText(basePath, "name", `Base path ${index + 1}`),
|
||||||
|
source: getText(basePath, "source"),
|
||||||
|
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
||||||
|
allow_individual: getBool(basePath, "allow_individual"),
|
||||||
|
unsent_warning: getBool(basePath, "unsent_warning")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
id: "base-path-campaign",
|
||||||
|
name: "Campaign files",
|
||||||
|
path: getText(attachments, "base_path", "."),
|
||||||
|
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
|
||||||
|
unsent_warning: false
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: ImportAttachmentBasePath | null): Record<string, unknown> {
|
function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: ImportAttachmentBasePath | null): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -749,9 +799,22 @@ function parseAddress(value: string): ImportedAddress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splitCell(value: string, separators: string): string[] {
|
function splitCell(value: string, separators: string): string[] {
|
||||||
const escaped = separators.split("").map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
|
const separatorSet = new Set(separators.split(""));
|
||||||
if (!escaped) return [value.trim()].filter(Boolean);
|
if (separatorSet.size === 0) return [value.trim()].filter(Boolean);
|
||||||
return value.split(new RegExp(`[${escaped}]+`)).map((item) => item.trim()).filter(Boolean);
|
const items: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
for (const char of value) {
|
||||||
|
if (separatorSet.has(char)) {
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) items.push(trimmed);
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
const trimmed = current.trim();
|
||||||
|
if (trimmed) items.push(trimmed);
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptionalBoolean(value: string, fallback: boolean): boolean {
|
function parseOptionalBoolean(value: string, fallback: boolean): boolean {
|
||||||
@@ -802,6 +865,14 @@ function getText(record: ImportRecord, key: string, fallback = ""): string {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBool(record: ImportRecord, key: string, fallback = false): boolean {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "string") return ["1", "true", "yes", "on"].includes(value.toLowerCase());
|
||||||
|
if (typeof value === "number") return value !== 0;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
function humanizeFieldName(value: string): string {
|
function humanizeFieldName(value: string): string {
|
||||||
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export function asArray(value: unknown): unknown[] {
|
|||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isSafeObjectPathSegment(segment: string): boolean {
|
||||||
|
return segment !== "__proto__" && segment !== "prototype" && segment !== "constructor";
|
||||||
|
}
|
||||||
|
|
||||||
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
|
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
|
||||||
return version?.raw_json ?? version?.campaign_json ?? {};
|
return version?.raw_json ?? version?.campaign_json ?? {};
|
||||||
}
|
}
|
||||||
@@ -179,10 +183,11 @@ export function getString(record: Record<string, unknown>, key: string, fallback
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
|
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
|
||||||
|
if (!path.every(isSafeObjectPathSegment)) return fallback;
|
||||||
let current: unknown = record;
|
let current: unknown = record;
|
||||||
for (const part of path) {
|
for (const part of path) {
|
||||||
if (!isRecord(current)) return fallback;
|
if (!isRecord(current)) return fallback;
|
||||||
current = current[part];
|
current = Object.getOwnPropertyDescriptor(current, part)?.value;
|
||||||
}
|
}
|
||||||
if (typeof current === "string" && current.trim()) return current;
|
if (typeof current === "string" && current.trim()) return current;
|
||||||
if (typeof current === "number" || typeof current === "boolean") return String(current);
|
if (typeof current === "number" || typeof current === "boolean") return String(current);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||||
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
|
import { asRecord, getCampaignJson, isRecord, isSafeObjectPathSegment } from "./campaignView";
|
||||||
|
|
||||||
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
|
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
|
||||||
@@ -22,15 +22,21 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
|
|||||||
raw.server = isRecord(raw.server) ? raw.server : {};
|
raw.server = isRecord(raw.server) ? raw.server : {};
|
||||||
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
|
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
|
||||||
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
|
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
|
||||||
|
const sourceAttachments = asRecord(raw.attachments);
|
||||||
raw.attachments = {
|
raw.attachments = {
|
||||||
base_path: ".",
|
base_path: ".",
|
||||||
allow_individual: false,
|
allow_individual: false,
|
||||||
send_without_attachments: true,
|
send_without_attachments: true,
|
||||||
|
send_without_attachments_behavior: "continue",
|
||||||
global: [],
|
global: [],
|
||||||
missing_behavior: "ask",
|
missing_behavior: "ask",
|
||||||
ambiguous_behavior: "ask",
|
ambiguous_behavior: "ask",
|
||||||
...asRecord(raw.attachments)
|
...sourceAttachments
|
||||||
};
|
};
|
||||||
|
const normalizedAttachments = asRecord(raw.attachments);
|
||||||
|
if (sourceAttachments.send_without_attachments_behavior === undefined) {
|
||||||
|
normalizedAttachments.send_without_attachments_behavior = getBool(normalizedAttachments, "send_without_attachments", true) ? "continue" : "block";
|
||||||
|
}
|
||||||
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
|
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
|
||||||
raw.validation_policy = {
|
raw.validation_policy = {
|
||||||
unsent_attachment_files: "warn",
|
unsent_attachment_files: "warn",
|
||||||
@@ -46,19 +52,32 @@ export function updateNested(
|
|||||||
path: string[],
|
path: string[],
|
||||||
value: unknown
|
value: unknown
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
|
if (!path.length || !path.every(isSafeObjectPathSegment)) return cloneJson(draft);
|
||||||
const next = cloneJson(draft);
|
const next = cloneJson(draft);
|
||||||
let current: Record<string, unknown> = next;
|
let current: Record<string, unknown> = next;
|
||||||
path.forEach((segment, index) => {
|
for (const [index, segment] of path.entries()) {
|
||||||
if (index === path.length - 1) {
|
if (index === path.length - 1) {
|
||||||
current[segment] = value;
|
Object.defineProperty(current, segment, {
|
||||||
return;
|
configurable: true,
|
||||||
}
|
enumerable: true,
|
||||||
const existing = current[segment];
|
value,
|
||||||
if (!isRecord(existing)) {
|
writable: true
|
||||||
current[segment] = {};
|
|
||||||
}
|
|
||||||
current = current[segment] as Record<string, unknown>;
|
|
||||||
});
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const existing = Object.getOwnPropertyDescriptor(current, segment)?.value;
|
||||||
|
if (!isRecord(existing)) {
|
||||||
|
Object.defineProperty(current, segment, {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
value: {},
|
||||||
|
writable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const child = Object.getOwnPropertyDescriptor(current, segment)?.value;
|
||||||
|
if (!isRecord(child)) return next;
|
||||||
|
current = child;
|
||||||
|
}
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,48 @@ export function recipientAddressTemplateFieldOptions(): Array<{ name: string; la
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isRecipientAddressPlaceholderName(name: string): boolean {
|
export function isRecipientAddressPlaceholderName(name: string): boolean {
|
||||||
const field = "(?:from|to|reply_to|cc|bcc)";
|
const normalized = name.trim();
|
||||||
return new RegExp(`^(?:all_)?${field}(?:\\.(?:email|name))?$`).test(name)
|
const allPrefix = "all_";
|
||||||
|| new RegExp(`^${field}\\[[1-9]\\d*\\](?:\\.(?:email|name))?$`).test(name)
|
const fieldName = recipientAddressFieldFromPlaceholder(normalized);
|
||||||
|| new RegExp(`^${field}\\.\\d+\\.(?:email|name|type)$`).test(name);
|
if (!fieldName) return false;
|
||||||
|
if (normalized === fieldName || normalized === `${allPrefix}${fieldName}`) return true;
|
||||||
|
if (["email", "name"].some((suffix) => normalized === `${fieldName}.${suffix}` || normalized === `${allPrefix}${fieldName}.${suffix}`)) return true;
|
||||||
|
if (isIndexedRecipientAddressPlaceholder(normalized, fieldName)) return true;
|
||||||
|
return isDottedRecipientAddressPlaceholder(normalized, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipientAddressFieldFromPlaceholder(value: string): string | null {
|
||||||
|
const candidates = [...RECIPIENT_ADDRESS_FIELD_IDS].sort((left, right) => right.length - left.length);
|
||||||
|
for (const field of candidates) {
|
||||||
|
if (value === field || value.startsWith(`${field}.`) || value.startsWith(`${field}[`)) return field;
|
||||||
|
const allField = `all_${field}`;
|
||||||
|
if (value === allField || value.startsWith(`${allField}.`)) return field;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPositiveInteger(value: string): boolean {
|
||||||
|
return value.length > 0 && value[0] !== "0" && [...value].every((char) => char >= "0" && char <= "9");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonNegativeInteger(value: string): boolean {
|
||||||
|
return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIndexedRecipientAddressPlaceholder(value: string, fieldName: string): boolean {
|
||||||
|
if (!value.startsWith(`${fieldName}[`)) return false;
|
||||||
|
const closeIndex = value.indexOf("]", fieldName.length + 1);
|
||||||
|
if (closeIndex < 0) return false;
|
||||||
|
const index = value.slice(fieldName.length + 1, closeIndex);
|
||||||
|
const suffix = value.slice(closeIndex + 1);
|
||||||
|
return isPositiveInteger(index) && (suffix === "" || suffix === ".email" || suffix === ".name");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDottedRecipientAddressPlaceholder(value: string, fieldName: string): boolean {
|
||||||
|
const prefix = `${fieldName}.`;
|
||||||
|
if (!value.startsWith(prefix)) return false;
|
||||||
|
const [index, suffix, extra] = value.slice(prefix.length).split(".");
|
||||||
|
return extra === undefined && isNonNegativeInteger(index) && ["email", "name", "type"].includes(suffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TemplatePlaceholder = {
|
export type TemplatePlaceholder = {
|
||||||
@@ -211,14 +249,20 @@ export function valueToPreview(value: unknown): string {
|
|||||||
|
|
||||||
export function removePlaceholderFromText(text: string, raw: string): string {
|
export function removePlaceholderFromText(text: string, raw: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
const escaped = escapeRegExp(raw.trim());
|
return replaceMatchingPlaceholders(text, raw, "");
|
||||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
|
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
|
||||||
if (!text) return text;
|
if (!text) return text;
|
||||||
const escaped = escapeRegExp(raw.trim());
|
return replaceMatchingPlaceholders(text, raw, replacement);
|
||||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), replacement);
|
}
|
||||||
|
|
||||||
|
function replaceMatchingPlaceholders(text: string, raw: string, replacement: string): string {
|
||||||
|
const target = raw.trim();
|
||||||
|
if (!target) return text;
|
||||||
|
return text.replace(/\{\{\s*([^}]+?)\s*\}\}|\$\{\s*([^}]+?)\s*\}/g, (match, braceRaw: string | undefined, dollarRaw: string | undefined) =>
|
||||||
|
(braceRaw ?? dollarRaw ?? "").trim() === target ? replacement : match
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
||||||
@@ -255,7 +299,3 @@ function previewValueFor(raw: string, context: Record<string, string>, ignoreEmp
|
|||||||
if (value !== undefined) return value;
|
if (value !== undefined) return value;
|
||||||
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegExp(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.all_imap_states.8546b84c": "All IMAP states",
|
"i18n:govoplan-campaign.all_imap_states.8546b84c": "All IMAP states",
|
||||||
"i18n:govoplan-campaign.all_smtp_states.739597b1": "All SMTP states",
|
"i18n:govoplan-campaign.all_smtp_states.739597b1": "All SMTP states",
|
||||||
"i18n:govoplan-campaign.all_templates.0bba114c": "All templates",
|
"i18n:govoplan-campaign.all_templates.0bba114c": "All templates",
|
||||||
|
"i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5": "All unique managed files matched by the current attachment rules are available in this list.",
|
||||||
"i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87": "Allow individual attachments",
|
"i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87": "Allow individual attachments",
|
||||||
"i18n:govoplan-campaign.allow_individual_bcc.a932d94b": "Allow individual BCC",
|
"i18n:govoplan-campaign.allow_individual_bcc.a932d94b": "Allow individual BCC",
|
||||||
"i18n:govoplan-campaign.allow_individual_cc.0457c0e2": "Allow individual CC",
|
"i18n:govoplan-campaign.allow_individual_cc.0457c0e2": "Allow individual CC",
|
||||||
@@ -95,6 +96,8 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
|
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
|
||||||
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
|
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
|
||||||
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
|
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
|
||||||
|
"i18n:govoplan-campaign.attachment_file_links.0be74fd1": "Attachment file links",
|
||||||
|
"i18n:govoplan-campaign.attachment_file_links_value.ce230e30": "Attachment file links ({value0})",
|
||||||
"i18n:govoplan-campaign.attachment_issues.69748336": "Attachment issues",
|
"i18n:govoplan-campaign.attachment_issues.69748336": "Attachment issues",
|
||||||
"i18n:govoplan-campaign.attachment_label.a340f70e": "Attachment label",
|
"i18n:govoplan-campaign.attachment_label.a340f70e": "Attachment label",
|
||||||
"i18n:govoplan-campaign.attachment_notice.b73a59fe": "Attachment notice",
|
"i18n:govoplan-campaign.attachment_notice.b73a59fe": "Attachment notice",
|
||||||
@@ -105,6 +108,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4": "Keep original ZIP entry name",
|
"i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4": "Keep original ZIP entry name",
|
||||||
"i18n:govoplan-campaign.message_filename_template.0b7ac934": "Message filename template",
|
"i18n:govoplan-campaign.message_filename_template.0b7ac934": "Message filename template",
|
||||||
"i18n:govoplan-campaign.zip_entry_name_template.83772a73": "ZIP entry name template",
|
"i18n:govoplan-campaign.zip_entry_name_template.83772a73": "ZIP entry name template",
|
||||||
|
"i18n:govoplan-campaign.attachment_preview_refreshed.50d5b50d": "Attachment preview refreshed.",
|
||||||
"i18n:govoplan-campaign.attachment_preview_unavailable.62211756": "Attachment preview unavailable",
|
"i18n:govoplan-campaign.attachment_preview_unavailable.62211756": "Attachment preview unavailable",
|
||||||
"i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8": "Attachment rule matched more files than expected.",
|
"i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8": "Attachment rule matched more files than expected.",
|
||||||
"i18n:govoplan-campaign.attachment_rule_s.0e5ee66a": "attachment rule(s),",
|
"i18n:govoplan-campaign.attachment_rule_s.0e5ee66a": "attachment rule(s),",
|
||||||
@@ -430,8 +434,17 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.last_loaded_value.35ef046a": "Last loaded: {value0}",
|
"i18n:govoplan-campaign.last_loaded_value.35ef046a": "Last loaded: {value0}",
|
||||||
"i18n:govoplan-campaign.last_message.83741110": "Last message",
|
"i18n:govoplan-campaign.last_message.83741110": "Last message",
|
||||||
"i18n:govoplan-campaign.last_result.110b888b": "Last result",
|
"i18n:govoplan-campaign.last_result.110b888b": "Last result",
|
||||||
|
"i18n:govoplan-campaign.link_and_lock.6eac996d": "Link and lock",
|
||||||
|
"i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653": "Link matched files before locking?",
|
||||||
|
"i18n:govoplan-campaign.link_value_file.4d4ce740": "Link {value0} file",
|
||||||
"i18n:govoplan-campaign.link_value_file_s.ca800d96": "Link {value0} file(s)",
|
"i18n:govoplan-campaign.link_value_file_s.ca800d96": "Link {value0} file(s)",
|
||||||
|
"i18n:govoplan-campaign.link_value_files.88b7e6a7": "Link {value0} files",
|
||||||
|
"i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc": "Linked: already part of the campaign file snapshot.",
|
||||||
|
"i18n:govoplan-campaign.linked_value_attachment_file_s_to_this_campaign.02e5ecf7": "Linked {value0} attachment file(s) to this campaign.",
|
||||||
"i18n:govoplan-campaign.linked.a089f600": "Linked",
|
"i18n:govoplan-campaign.linked.a089f600": "Linked",
|
||||||
|
"i18n:govoplan-campaign.linking.a5f54e0f": "Linking",
|
||||||
|
"i18n:govoplan-campaign.linking_matched_attachment_files.92f38088": "Linking matched attachment files…",
|
||||||
|
"i18n:govoplan-campaign.linking_matched_files_then_validating_the_campa.0d48a1d0": "Linking matched files, then validating the campaign…",
|
||||||
"i18n:govoplan-campaign.linking.6f640897": "Linking…",
|
"i18n:govoplan-campaign.linking.6f640897": "Linking…",
|
||||||
"i18n:govoplan-campaign.load_from_library.327ada7c": "Load from library",
|
"i18n:govoplan-campaign.load_from_library.327ada7c": "Load from library",
|
||||||
"i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891": "Load IMAP diagnostics",
|
"i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891": "Load IMAP diagnostics",
|
||||||
@@ -483,6 +496,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.map.ab478f3e": "Map",
|
"i18n:govoplan-campaign.map.ab478f3e": "Map",
|
||||||
"i18n:govoplan-campaign.mapping_value": "Mapping: {value0}",
|
"i18n:govoplan-campaign.mapping_value": "Mapping: {value0}",
|
||||||
"i18n:govoplan-campaign.marks_the_message_for_review.d63beb24": "Marks the message for review.",
|
"i18n:govoplan-campaign.marks_the_message_for_review.d63beb24": "Marks the message for review.",
|
||||||
|
"i18n:govoplan-campaign.matched.1bf3ec5b": "Matched",
|
||||||
"i18n:govoplan-campaign.matched_attachments.ead1eeb1": "Matched attachments",
|
"i18n:govoplan-campaign.matched_attachments.ead1eeb1": "Matched attachments",
|
||||||
"i18n:govoplan-campaign.matched_files.f79c63bb": "Matched files",
|
"i18n:govoplan-campaign.matched_files.f79c63bb": "Matched files",
|
||||||
"i18n:govoplan-campaign.matching_of.66a3778e": "matching of",
|
"i18n:govoplan-campaign.matching_of.66a3778e": "matching of",
|
||||||
@@ -541,6 +555,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.my_files.71d01a41": "My files",
|
"i18n:govoplan-campaign.my_files.71d01a41": "My files",
|
||||||
"i18n:govoplan-campaign.name_and_scenario.f2bc5241": "Name and scenario",
|
"i18n:govoplan-campaign.name_and_scenario.f2bc5241": "Name and scenario",
|
||||||
"i18n:govoplan-campaign.name.709a2322": "Name",
|
"i18n:govoplan-campaign.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-campaign.need_link.fa4ab530": "Need link",
|
||||||
"i18n:govoplan-campaign.need_linking.a7617722": "Need linking",
|
"i18n:govoplan-campaign.need_linking.a7617722": "Need linking",
|
||||||
"i18n:govoplan-campaign.need_review.201a4493": "Need review",
|
"i18n:govoplan-campaign.need_review.201a4493": "Need review",
|
||||||
"i18n:govoplan-campaign.needs_attention.a126722e": "Needs attention",
|
"i18n:govoplan-campaign.needs_attention.a126722e": "Needs attention",
|
||||||
@@ -588,6 +603,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.no_individual_attachment_source.6b54176a": "No individual attachment source",
|
"i18n:govoplan-campaign.no_individual_attachment_source.6b54176a": "No individual attachment source",
|
||||||
"i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb": "No inline recipients are available yet.",
|
"i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb": "No inline recipients are available yet.",
|
||||||
"i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5": "No jobs match the current filters.",
|
"i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5": "No jobs match the current filters.",
|
||||||
|
"i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c": "No managed files matched the current attachment patterns.",
|
||||||
"i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6": "No message content is available.",
|
"i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6": "No message content is available.",
|
||||||
"i18n:govoplan-campaign.no_message_id.43390ef7": "No message ID",
|
"i18n:govoplan-campaign.no_message_id.43390ef7": "No message ID",
|
||||||
"i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8": "No messages match the active filters.",
|
"i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8": "No messages match the active filters.",
|
||||||
@@ -693,6 +709,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.planned_address_actions.1d4a056a": "Planned address actions",
|
"i18n:govoplan-campaign.planned_address_actions.1d4a056a": "Planned address actions",
|
||||||
"i18n:govoplan-campaign.policies.f03ff937": "POLICIES",
|
"i18n:govoplan-campaign.policies.f03ff937": "POLICIES",
|
||||||
"i18n:govoplan-campaign.policy.bb9cf141": "Policy",
|
"i18n:govoplan-campaign.policy.bb9cf141": "Policy",
|
||||||
|
"i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af": "Preview includes already linked files and accessible unlinked candidates. Locking can link candidates before validation.",
|
||||||
"i18n:govoplan-campaign.preview_message_navigation.d28a8dc0": "Preview message navigation",
|
"i18n:govoplan-campaign.preview_message_navigation.d28a8dc0": "Preview message navigation",
|
||||||
"i18n:govoplan-campaign.preview.4bf30626": "Preview:",
|
"i18n:govoplan-campaign.preview.4bf30626": "Preview:",
|
||||||
"i18n:govoplan-campaign.preview.f1fbb2b4": "Preview",
|
"i18n:govoplan-campaign.preview.f1fbb2b4": "Preview",
|
||||||
@@ -757,6 +774,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.refresh_matches.11e36411": "Refresh matches",
|
"i18n:govoplan-campaign.refresh_matches.11e36411": "Refresh matches",
|
||||||
"i18n:govoplan-campaign.refresh_status.ade15a52": "Refresh status",
|
"i18n:govoplan-campaign.refresh_status.ade15a52": "Refresh status",
|
||||||
"i18n:govoplan-campaign.refresh.56e3badc": "Refresh",
|
"i18n:govoplan-campaign.refresh.56e3badc": "Refresh",
|
||||||
|
"i18n:govoplan-campaign.refreshing.505dddc9": "Refreshing",
|
||||||
"i18n:govoplan-campaign.refreshing_queue_status.2a7dea57": "Refreshing queue status…",
|
"i18n:govoplan-campaign.refreshing_queue_status.2a7dea57": "Refreshing queue status…",
|
||||||
"i18n:govoplan-campaign.refreshing_status.d8965739": "Refreshing status…",
|
"i18n:govoplan-campaign.refreshing_status.d8965739": "Refreshing status…",
|
||||||
"i18n:govoplan-campaign.reload_page.37614e96": "Reload page",
|
"i18n:govoplan-campaign.reload_page.37614e96": "Reload page",
|
||||||
@@ -886,6 +904,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
|
"i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
|
||||||
"i18n:govoplan-campaign.shared_group_files.fffa6e27": "Shared group files",
|
"i18n:govoplan-campaign.shared_group_files.fffa6e27": "Shared group files",
|
||||||
"i18n:govoplan-campaign.shared_list.b3c94b39": "Shared list",
|
"i18n:govoplan-campaign.shared_list.b3c94b39": "Shared list",
|
||||||
|
"i18n:govoplan-campaign.shared_source.7d4a1bf2": "Shared source",
|
||||||
"i18n:govoplan-campaign.shared_with.6203f449": "Shared with",
|
"i18n:govoplan-campaign.shared_with.6203f449": "Shared with",
|
||||||
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
||||||
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
||||||
@@ -1019,6 +1038,8 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.undefined_field_reference.4ff8e266": "Undefined field reference",
|
"i18n:govoplan-campaign.undefined_field_reference.4ff8e266": "Undefined field reference",
|
||||||
"i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282": "Undefined placeholder namespace detected:",
|
"i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282": "Undefined placeholder namespace detected:",
|
||||||
"i18n:govoplan-campaign.unknown.bc7819b3": "Unknown",
|
"i18n:govoplan-campaign.unknown.bc7819b3": "Unknown",
|
||||||
|
"i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998": "Unlinked candidate files are not yet part of the campaign snapshot. They will be linked when you confirm locking.",
|
||||||
|
"i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433": "Unlinked: candidate match, potentially missing until linked.",
|
||||||
"i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468": "Unlock temporary lock?",
|
"i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468": "Unlock temporary lock?",
|
||||||
"i18n:govoplan-campaign.unlock_validation.e3066247": "Unlock validation?",
|
"i18n:govoplan-campaign.unlock_validation.e3066247": "Unlock validation?",
|
||||||
"i18n:govoplan-campaign.unlock_validation.f01952b6": "Unlock validation",
|
"i18n:govoplan-campaign.unlock_validation.f01952b6": "Unlock validation",
|
||||||
@@ -1092,7 +1113,10 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9": "{value0} direct file(s), {value1} rule(s) / pattern(s)",
|
"i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9": "{value0} direct file(s), {value1} rule(s) / pattern(s)",
|
||||||
"i18n:govoplan-campaign.value_encryption_value": "{value0} · Encryption: {value1}",
|
"i18n:govoplan-campaign.value_encryption_value": "{value0} · Encryption: {value1}",
|
||||||
"i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7": "{value0} individual attachment {value1} this source. Disabling it will remove those recipient-specific attachment entries.",
|
"i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7": "{value0} individual attachment {value1} this source. Disabling it will remove those recipient-specific attachment entries.",
|
||||||
|
"i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824": "{value0} matched attachment file link",
|
||||||
|
"i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a": "{value0} matched attachment file links",
|
||||||
"i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991": "{value0} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.",
|
"i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991": "{value0} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.",
|
||||||
|
"i18n:govoplan-campaign.value_matched_file_s_are_not_yet_linked_to_t.43d6c926": "{value0} matched file(s) are not yet linked to this campaign. Link them now and continue locking?",
|
||||||
"i18n:govoplan-campaign.value_min.c9d89eae": "{value0}/min",
|
"i18n:govoplan-campaign.value_min.c9d89eae": "{value0}/min",
|
||||||
"i18n:govoplan-campaign.value_minute.aeb1a9ea": "{value0} / minute",
|
"i18n:govoplan-campaign.value_minute.aeb1a9ea": "{value0} / minute",
|
||||||
"i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0": "{value0}. Saving is disabled until this is corrected.",
|
"i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0": "{value0}. Saving is disabled until this is corrected.",
|
||||||
@@ -1111,6 +1135,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.version.2da600bf": "Version",
|
"i18n:govoplan-campaign.version.2da600bf": "Version",
|
||||||
"i18n:govoplan-campaign.versions_and_import_export.cc05cb43": "Versions and import/export",
|
"i18n:govoplan-campaign.versions_and_import_export.cc05cb43": "Versions and import/export",
|
||||||
"i18n:govoplan-campaign.versions.a239107e": "Versions",
|
"i18n:govoplan-campaign.versions.a239107e": "Versions",
|
||||||
|
"i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951": "View all {value0} matched attachment file links",
|
||||||
"i18n:govoplan-campaign.waiting.33d30632": "Waiting",
|
"i18n:govoplan-campaign.waiting.33d30632": "Waiting",
|
||||||
"i18n:govoplan-campaign.warn.3009d557": "Warn",
|
"i18n:govoplan-campaign.warn.3009d557": "Warn",
|
||||||
"i18n:govoplan-campaign.warning.e9c45563": "Warning",
|
"i18n:govoplan-campaign.warning.e9c45563": "Warning",
|
||||||
@@ -1187,6 +1212,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.all_imap_states.8546b84c": "All IMAP states",
|
"i18n:govoplan-campaign.all_imap_states.8546b84c": "All IMAP states",
|
||||||
"i18n:govoplan-campaign.all_smtp_states.739597b1": "All SMTP states",
|
"i18n:govoplan-campaign.all_smtp_states.739597b1": "All SMTP states",
|
||||||
"i18n:govoplan-campaign.all_templates.0bba114c": "All templates",
|
"i18n:govoplan-campaign.all_templates.0bba114c": "All templates",
|
||||||
|
"i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5": "Diese Liste enthält alle eindeutigen verwalteten Dateien, die den aktuellen Anhangsregeln entsprechen.",
|
||||||
"i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87": "Allow individual attachments",
|
"i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87": "Allow individual attachments",
|
||||||
"i18n:govoplan-campaign.allow_individual_bcc.a932d94b": "Allow individual BCC",
|
"i18n:govoplan-campaign.allow_individual_bcc.a932d94b": "Allow individual BCC",
|
||||||
"i18n:govoplan-campaign.allow_individual_cc.0457c0e2": "Allow individual CC",
|
"i18n:govoplan-campaign.allow_individual_cc.0457c0e2": "Allow individual CC",
|
||||||
@@ -1222,6 +1248,8 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
|
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
|
||||||
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
|
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
|
||||||
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
|
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
|
||||||
|
"i18n:govoplan-campaign.attachment_file_links.0be74fd1": "Verknüpfungen von Anhangsdateien",
|
||||||
|
"i18n:govoplan-campaign.attachment_file_links_value.ce230e30": "Verknüpfungen von Anhangsdateien ({value0})",
|
||||||
"i18n:govoplan-campaign.attachment_issues.69748336": "Attachment issues",
|
"i18n:govoplan-campaign.attachment_issues.69748336": "Attachment issues",
|
||||||
"i18n:govoplan-campaign.attachment_label.a340f70e": "Attachment label",
|
"i18n:govoplan-campaign.attachment_label.a340f70e": "Attachment label",
|
||||||
"i18n:govoplan-campaign.attachment_notice.b73a59fe": "Attachment notice",
|
"i18n:govoplan-campaign.attachment_notice.b73a59fe": "Attachment notice",
|
||||||
@@ -1232,6 +1260,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4": "Urspruenglichen ZIP-Eintragsnamen behalten",
|
"i18n:govoplan-campaign.keep_original_zip_entry_name.d51558c4": "Urspruenglichen ZIP-Eintragsnamen behalten",
|
||||||
"i18n:govoplan-campaign.message_filename_template.0b7ac934": "Dateinamenvorlage fuer Nachrichten",
|
"i18n:govoplan-campaign.message_filename_template.0b7ac934": "Dateinamenvorlage fuer Nachrichten",
|
||||||
"i18n:govoplan-campaign.zip_entry_name_template.83772a73": "Namensvorlage fuer ZIP-Eintraege",
|
"i18n:govoplan-campaign.zip_entry_name_template.83772a73": "Namensvorlage fuer ZIP-Eintraege",
|
||||||
|
"i18n:govoplan-campaign.attachment_preview_refreshed.50d5b50d": "Attachment preview refreshed.",
|
||||||
"i18n:govoplan-campaign.attachment_preview_unavailable.62211756": "Attachment preview unavailable",
|
"i18n:govoplan-campaign.attachment_preview_unavailable.62211756": "Attachment preview unavailable",
|
||||||
"i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8": "Attachment rule matched more files than expected.",
|
"i18n:govoplan-campaign.attachment_rule_matched_more_files_than_expected.3e2110e8": "Attachment rule matched more files than expected.",
|
||||||
"i18n:govoplan-campaign.attachment_rule_s.0e5ee66a": "attachment rule(s),",
|
"i18n:govoplan-campaign.attachment_rule_s.0e5ee66a": "attachment rule(s),",
|
||||||
@@ -1557,8 +1586,17 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.last_loaded_value.35ef046a": "Last loaded: {value0}",
|
"i18n:govoplan-campaign.last_loaded_value.35ef046a": "Last loaded: {value0}",
|
||||||
"i18n:govoplan-campaign.last_message.83741110": "Last message",
|
"i18n:govoplan-campaign.last_message.83741110": "Last message",
|
||||||
"i18n:govoplan-campaign.last_result.110b888b": "Last result",
|
"i18n:govoplan-campaign.last_result.110b888b": "Last result",
|
||||||
|
"i18n:govoplan-campaign.link_and_lock.6eac996d": "Link and lock",
|
||||||
|
"i18n:govoplan-campaign.link_matched_files_before_locking.5d1cf653": "Link matched files before locking?",
|
||||||
|
"i18n:govoplan-campaign.link_value_file.4d4ce740": "{value0} Datei verknüpfen",
|
||||||
"i18n:govoplan-campaign.link_value_file_s.ca800d96": "Link {value0} file(s)",
|
"i18n:govoplan-campaign.link_value_file_s.ca800d96": "Link {value0} file(s)",
|
||||||
"i18n:govoplan-campaign.linked.a089f600": "Linked",
|
"i18n:govoplan-campaign.link_value_files.88b7e6a7": "{value0} Dateien verknüpfen",
|
||||||
|
"i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc": "Verknüpft: bereits Teil des Kampagnen-Datei-Snapshots.",
|
||||||
|
"i18n:govoplan-campaign.linked_value_attachment_file_s_to_this_campaign.02e5ecf7": "Linked {value0} attachment file(s) to this campaign.",
|
||||||
|
"i18n:govoplan-campaign.linked.a089f600": "Verknüpft",
|
||||||
|
"i18n:govoplan-campaign.linking.a5f54e0f": "Wird verknüpft",
|
||||||
|
"i18n:govoplan-campaign.linking_matched_attachment_files.92f38088": "Linking matched attachment files…",
|
||||||
|
"i18n:govoplan-campaign.linking_matched_files_then_validating_the_campa.0d48a1d0": "Linking matched files, then validating the campaign…",
|
||||||
"i18n:govoplan-campaign.linking.6f640897": "Linking…",
|
"i18n:govoplan-campaign.linking.6f640897": "Linking…",
|
||||||
"i18n:govoplan-campaign.load_from_library.327ada7c": "Load from library",
|
"i18n:govoplan-campaign.load_from_library.327ada7c": "Load from library",
|
||||||
"i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891": "Load IMAP diagnostics",
|
"i18n:govoplan-campaign.load_imap_diagnostics.8ebb1891": "Load IMAP diagnostics",
|
||||||
@@ -1610,6 +1648,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.map.ab478f3e": "Map",
|
"i18n:govoplan-campaign.map.ab478f3e": "Map",
|
||||||
"i18n:govoplan-campaign.mapping_value": "Mapping: {value0}",
|
"i18n:govoplan-campaign.mapping_value": "Mapping: {value0}",
|
||||||
"i18n:govoplan-campaign.marks_the_message_for_review.d63beb24": "Marks the message for review.",
|
"i18n:govoplan-campaign.marks_the_message_for_review.d63beb24": "Marks the message for review.",
|
||||||
|
"i18n:govoplan-campaign.matched.1bf3ec5b": "Treffer",
|
||||||
"i18n:govoplan-campaign.matched_attachments.ead1eeb1": "Matched attachments",
|
"i18n:govoplan-campaign.matched_attachments.ead1eeb1": "Matched attachments",
|
||||||
"i18n:govoplan-campaign.matched_files.f79c63bb": "Matched files",
|
"i18n:govoplan-campaign.matched_files.f79c63bb": "Matched files",
|
||||||
"i18n:govoplan-campaign.matching_of.66a3778e": "matching of",
|
"i18n:govoplan-campaign.matching_of.66a3778e": "matching of",
|
||||||
@@ -1668,6 +1707,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.my_files.71d01a41": "My files",
|
"i18n:govoplan-campaign.my_files.71d01a41": "My files",
|
||||||
"i18n:govoplan-campaign.name_and_scenario.f2bc5241": "Name and scenario",
|
"i18n:govoplan-campaign.name_and_scenario.f2bc5241": "Name and scenario",
|
||||||
"i18n:govoplan-campaign.name.709a2322": "Name",
|
"i18n:govoplan-campaign.name.709a2322": "Name",
|
||||||
|
"i18n:govoplan-campaign.need_link.fa4ab530": "Zu verknüpfen",
|
||||||
"i18n:govoplan-campaign.need_linking.a7617722": "Need linking",
|
"i18n:govoplan-campaign.need_linking.a7617722": "Need linking",
|
||||||
"i18n:govoplan-campaign.need_review.201a4493": "Need review",
|
"i18n:govoplan-campaign.need_review.201a4493": "Need review",
|
||||||
"i18n:govoplan-campaign.needs_attention.a126722e": "Benötigt Aufmerksamkeit",
|
"i18n:govoplan-campaign.needs_attention.a126722e": "Benötigt Aufmerksamkeit",
|
||||||
@@ -1715,6 +1755,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.no_individual_attachment_source.6b54176a": "No individual attachment source",
|
"i18n:govoplan-campaign.no_individual_attachment_source.6b54176a": "No individual attachment source",
|
||||||
"i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb": "No inline recipients are available yet.",
|
"i18n:govoplan-campaign.no_inline_recipients_are_available_yet.e405bacb": "No inline recipients are available yet.",
|
||||||
"i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5": "No jobs match the current filters.",
|
"i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5": "No jobs match the current filters.",
|
||||||
|
"i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c": "Keine verwalteten Dateien entsprechen den aktuellen Anhangsmustern.",
|
||||||
"i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6": "No message content is available.",
|
"i18n:govoplan-campaign.no_message_content_is_available.54e8e7e6": "No message content is available.",
|
||||||
"i18n:govoplan-campaign.no_message_id.43390ef7": "No message ID",
|
"i18n:govoplan-campaign.no_message_id.43390ef7": "No message ID",
|
||||||
"i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8": "No messages match the active filters.",
|
"i18n:govoplan-campaign.no_messages_match_the_active_filters.14811cc8": "No messages match the active filters.",
|
||||||
@@ -1820,6 +1861,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.planned_address_actions.1d4a056a": "Planned address actions",
|
"i18n:govoplan-campaign.planned_address_actions.1d4a056a": "Planned address actions",
|
||||||
"i18n:govoplan-campaign.policies.f03ff937": "POLICIES",
|
"i18n:govoplan-campaign.policies.f03ff937": "POLICIES",
|
||||||
"i18n:govoplan-campaign.policy.bb9cf141": "Richtlinie",
|
"i18n:govoplan-campaign.policy.bb9cf141": "Richtlinie",
|
||||||
|
"i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af": "Die Vorschau enthält bereits verknüpfte Dateien und zugängliche, noch nicht verknüpfte Kandidaten. Beim Sperren können Kandidaten vor der Validierung verknüpft werden.",
|
||||||
"i18n:govoplan-campaign.preview_message_navigation.d28a8dc0": "Preview message navigation",
|
"i18n:govoplan-campaign.preview_message_navigation.d28a8dc0": "Preview message navigation",
|
||||||
"i18n:govoplan-campaign.preview.4bf30626": "Preview:",
|
"i18n:govoplan-campaign.preview.4bf30626": "Preview:",
|
||||||
"i18n:govoplan-campaign.preview.f1fbb2b4": "Vorschau",
|
"i18n:govoplan-campaign.preview.f1fbb2b4": "Vorschau",
|
||||||
@@ -1883,7 +1925,8 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.recording_the_completed_message_review.16f58b81": "Recording the completed message review…",
|
"i18n:govoplan-campaign.recording_the_completed_message_review.16f58b81": "Recording the completed message review…",
|
||||||
"i18n:govoplan-campaign.refresh_matches.11e36411": "Refresh matches",
|
"i18n:govoplan-campaign.refresh_matches.11e36411": "Refresh matches",
|
||||||
"i18n:govoplan-campaign.refresh_status.ade15a52": "Refresh status",
|
"i18n:govoplan-campaign.refresh_status.ade15a52": "Refresh status",
|
||||||
"i18n:govoplan-campaign.refresh.56e3badc": "Refresh",
|
"i18n:govoplan-campaign.refresh.56e3badc": "Aktualisieren",
|
||||||
|
"i18n:govoplan-campaign.refreshing.505dddc9": "Wird aktualisiert",
|
||||||
"i18n:govoplan-campaign.refreshing_queue_status.2a7dea57": "Refreshing queue status…",
|
"i18n:govoplan-campaign.refreshing_queue_status.2a7dea57": "Refreshing queue status…",
|
||||||
"i18n:govoplan-campaign.refreshing_status.d8965739": "Refreshing status…",
|
"i18n:govoplan-campaign.refreshing_status.d8965739": "Refreshing status…",
|
||||||
"i18n:govoplan-campaign.reload_page.37614e96": "Reload page",
|
"i18n:govoplan-campaign.reload_page.37614e96": "Reload page",
|
||||||
@@ -2013,6 +2056,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
|
"i18n:govoplan-campaign.shared_group_address_books_and_lists.12bac69d": "Shared group address books and lists.",
|
||||||
"i18n:govoplan-campaign.shared_group_files.fffa6e27": "Shared group files",
|
"i18n:govoplan-campaign.shared_group_files.fffa6e27": "Shared group files",
|
||||||
"i18n:govoplan-campaign.shared_list.b3c94b39": "Shared list",
|
"i18n:govoplan-campaign.shared_list.b3c94b39": "Shared list",
|
||||||
|
"i18n:govoplan-campaign.shared_source.7d4a1bf2": "Gemeinsame Quelle",
|
||||||
"i18n:govoplan-campaign.shared_with.6203f449": "Freigegeben für",
|
"i18n:govoplan-campaign.shared_with.6203f449": "Freigegeben für",
|
||||||
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
||||||
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
||||||
@@ -2146,6 +2190,8 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.undefined_field_reference.4ff8e266": "Undefined field reference",
|
"i18n:govoplan-campaign.undefined_field_reference.4ff8e266": "Undefined field reference",
|
||||||
"i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282": "Undefined placeholder namespace detected:",
|
"i18n:govoplan-campaign.undefined_placeholder_namespace_detected.2ef5c282": "Undefined placeholder namespace detected:",
|
||||||
"i18n:govoplan-campaign.unknown.bc7819b3": "Unknown",
|
"i18n:govoplan-campaign.unknown.bc7819b3": "Unknown",
|
||||||
|
"i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998": "Nicht verknüpfte Kandidatendateien sind noch nicht Teil des Kampagnen-Snapshots. Sie werden verknüpft, wenn Sie das Sperren bestätigen.",
|
||||||
|
"i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433": "Nicht verknüpft: möglicher Treffer, der ohne Verknüpfung fehlen kann.",
|
||||||
"i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468": "Unlock temporary lock?",
|
"i18n:govoplan-campaign.unlock_temporary_lock.8a3ad468": "Unlock temporary lock?",
|
||||||
"i18n:govoplan-campaign.unlock_validation.e3066247": "Unlock validation?",
|
"i18n:govoplan-campaign.unlock_validation.e3066247": "Unlock validation?",
|
||||||
"i18n:govoplan-campaign.unlock_validation.f01952b6": "Unlock validation",
|
"i18n:govoplan-campaign.unlock_validation.f01952b6": "Unlock validation",
|
||||||
@@ -2219,7 +2265,10 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9": "{value0} direct file(s), {value1} rule(s) / pattern(s)",
|
"i18n:govoplan-campaign.value_direct_file_s_value_rule_s_pattern_s.df9b46d9": "{value0} direct file(s), {value1} rule(s) / pattern(s)",
|
||||||
"i18n:govoplan-campaign.value_encryption_value": "{value0} · Verschlüsselung: {value1}",
|
"i18n:govoplan-campaign.value_encryption_value": "{value0} · Verschlüsselung: {value1}",
|
||||||
"i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7": "{value0} individual attachment {value1} this source. Disabling it will remove those recipient-specific attachment entries.",
|
"i18n:govoplan-campaign.value_individual_attachment_value_this_source_di.3c7428e7": "{value0} individual attachment {value1} this source. Disabling it will remove those recipient-specific attachment entries.",
|
||||||
|
"i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824": "{value0} gefundene Verknüpfung einer Anhangsdatei",
|
||||||
|
"i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a": "{value0} gefundene Verknüpfungen von Anhangsdateien",
|
||||||
"i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991": "{value0} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.",
|
"i18n:govoplan-campaign.value_message_s_contain_warnings_or_exclusions_b.e7d13991": "{value0} message(s) contain warnings or exclusions but no blocking condition. Completing review will acknowledge these conditions together without opening each message.",
|
||||||
|
"i18n:govoplan-campaign.value_matched_file_s_are_not_yet_linked_to_t.43d6c926": "{value0} matched file(s) are not yet linked to this campaign. Link them now and continue locking?",
|
||||||
"i18n:govoplan-campaign.value_min.c9d89eae": "{value0}/min",
|
"i18n:govoplan-campaign.value_min.c9d89eae": "{value0}/min",
|
||||||
"i18n:govoplan-campaign.value_minute.aeb1a9ea": "{value0} / minute",
|
"i18n:govoplan-campaign.value_minute.aeb1a9ea": "{value0} / minute",
|
||||||
"i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0": "{value0}. Saving is disabled until this is corrected.",
|
"i18n:govoplan-campaign.value_saving_is_disabled_until_this_is_corrected.cd0a2ca0": "{value0}. Saving is disabled until this is corrected.",
|
||||||
@@ -2238,6 +2287,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.version.2da600bf": "Version",
|
"i18n:govoplan-campaign.version.2da600bf": "Version",
|
||||||
"i18n:govoplan-campaign.versions_and_import_export.cc05cb43": "Versions and import/export",
|
"i18n:govoplan-campaign.versions_and_import_export.cc05cb43": "Versions and import/export",
|
||||||
"i18n:govoplan-campaign.versions.a239107e": "Versions",
|
"i18n:govoplan-campaign.versions.a239107e": "Versions",
|
||||||
|
"i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951": "Alle {value0} gefundenen Verknüpfungen von Anhangsdateien anzeigen",
|
||||||
"i18n:govoplan-campaign.waiting.33d30632": "Waiting",
|
"i18n:govoplan-campaign.waiting.33d30632": "Waiting",
|
||||||
"i18n:govoplan-campaign.warn.3009d557": "Warn",
|
"i18n:govoplan-campaign.warn.3009d557": "Warn",
|
||||||
"i18n:govoplan-campaign.warning.e9c45563": "Warning",
|
"i18n:govoplan-campaign.warning.e9c45563": "Warning",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export { default } from "./module";
|
|||||||
export * from "./module";
|
export * from "./module";
|
||||||
export * from "./api/campaigns";
|
export * from "./api/campaigns";
|
||||||
export * from "./features/campaigns/policyUi";
|
export * from "./features/campaigns/policyUi";
|
||||||
export { default as AddressBookPage } from "./features/addressbook/AddressBookPage";
|
|
||||||
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
|
export { default as CampaignListPage } from "./features/campaigns/CampaignListPage";
|
||||||
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
|
export { default as CampaignWorkspace } from "./features/campaigns/CampaignWorkspace";
|
||||||
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";
|
export { default as OperatorQueuePage } from "./features/operator/OperatorQueuePage";
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
|||||||
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
|
{ id: "fields", label: "i18n:govoplan-campaign.fields.e8b68527" },
|
||||||
{ id: "files", label: "i18n:govoplan-campaign.attachments.6771ade6" },
|
{ id: "files", label: "i18n:govoplan-campaign.attachments.6771ade6" },
|
||||||
{ id: "recipients", label: "i18n:govoplan-campaign.sender_recipients.922c6d24" },
|
{ id: "recipients", label: "i18n:govoplan-campaign.sender_recipients.922c6d24" },
|
||||||
{ id: "recipient-data", label: "i18n:govoplan-campaign.recipient_data.c2baaf10" },
|
|
||||||
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06" }]
|
{ id: "template", label: "i18n:govoplan-campaign.template.3ec1ae06" }]
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { getCampaign } from "./api/campaigns";
|
|||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import "./styles/campaign-workspace.css";
|
import "./styles/campaign-workspace.css";
|
||||||
|
|
||||||
const AddressBookPage = lazy(() => import("./features/addressbook/AddressBookPage"));
|
|
||||||
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
|
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
|
||||||
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
||||||
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
|
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
|
||||||
@@ -35,7 +34,6 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
order: 30
|
order: 30
|
||||||
},
|
},
|
||||||
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
|
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
|
||||||
{ to: "/address-book", label: "i18n:govoplan-campaign.address_book.f6327f59", iconName: "users", order: 80 },
|
|
||||||
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
|
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
@@ -43,7 +41,6 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||||
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
|
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
|
||||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
||||||
{ path: "/address-book", order: 80, render: () => createElement(AddressBookPage) },
|
|
||||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
|
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
43
webui/tests/message-preview-overlay-structure.test.mjs
Normal file
43
webui/tests/message-preview-overlay-structure.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function assert(condition, message) {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlaySource = readFileSync(
|
||||||
|
"src/features/campaigns/components/MessagePreviewOverlay.tsx",
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const styles = readFileSync("src/styles/campaign-workspace.css", "utf8");
|
||||||
|
|
||||||
|
assert(
|
||||||
|
overlaySource.includes("overlay-backdrop message-preview-backdrop"),
|
||||||
|
"message previews expose their responsive backdrop hook"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
overlaySource.includes('type="button" className="modal-close" aria-label={closeLabel} title={closeLabel}'),
|
||||||
|
"the close control has stable button semantics, an accessible name, and a tooltip"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
styles.includes("height: min(780px, calc(100dvh - 48px));"),
|
||||||
|
"desktop previews use a stable responsive height"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
styles.includes("flex: 1 1 auto;") &&
|
||||||
|
styles.includes("min-height: 0;") &&
|
||||||
|
styles.includes("overflow: auto;") &&
|
||||||
|
styles.includes("scrollbar-gutter: stable;"),
|
||||||
|
"the preview body owns scrolling without moving the surrounding actions"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
styles.includes(".message-preview-modal .modal-footer") &&
|
||||||
|
styles.includes("min-height: 68px;"),
|
||||||
|
"the preview footer keeps a stable desktop action area"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
styles.includes("height: calc(100dvh - 16px);") &&
|
||||||
|
styles.includes("height: clamp(210px, 38dvh, 360px);"),
|
||||||
|
"small viewports use their available dynamic height"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Message preview overlay structure checks passed.");
|
||||||
113
webui/tests/review-preview-ui.test.ts
Normal file
113
webui/tests/review-preview-ui.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
attachmentPreviewLinkableFiles,
|
||||||
|
attachmentPreviewMatchedFiles,
|
||||||
|
type AttachmentPreviewFileLike,
|
||||||
|
type AttachmentPreviewLike
|
||||||
|
} from "../src/features/campaigns/utils/attachmentPreview";
|
||||||
|
|
||||||
|
declare function require(name: string): {
|
||||||
|
readFileSync(path: string, encoding: string): string;
|
||||||
|
createHash(algorithm: string): {
|
||||||
|
update(value: string): {digest(encoding: "hex"): string};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const { readFileSync } = require("node:fs");
|
||||||
|
const { createHash } = require("node:crypto");
|
||||||
|
|
||||||
|
function assert(condition: unknown, message = "assertion failed"): void {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewFile(index: number): AttachmentPreviewFileLike {
|
||||||
|
return {
|
||||||
|
id: `file-${index}`,
|
||||||
|
display_path: `/campaign/attachments/file-${String(index).padStart(2, "0")}.pdf`,
|
||||||
|
filename: `file-${String(index).padStart(2, "0")}.pdf`,
|
||||||
|
linked_to_campaign: index % 2 === 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewRule(matches: AttachmentPreviewFileLike[]): {matches: AttachmentPreviewFileLike[]} {
|
||||||
|
return {
|
||||||
|
matches
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from({ length: 25 }, (_, index) => previewFile(index));
|
||||||
|
const duplicatePromotedToLinked = { ...files[1], linked_to_campaign: true };
|
||||||
|
const preview: AttachmentPreviewLike<AttachmentPreviewFileLike> = {
|
||||||
|
rules: [
|
||||||
|
previewRule(files.slice(0, 14)),
|
||||||
|
previewRule([...files.slice(14), duplicatePromotedToLinked])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const matched = attachmentPreviewMatchedFiles(preview);
|
||||||
|
assert(matched.length === 25, "all unique attachment matches remain available; the review model must not truncate at twelve");
|
||||||
|
assert(matched.find((file) => file.id === "file-1")?.linked_to_campaign === true, "duplicate matches preserve the strongest linked state");
|
||||||
|
assert(matched.every((file, index) => index === 0 || Number(matched[index - 1].linked_to_campaign === false) <= Number(file.linked_to_campaign === false)), "linked matches sort before unlinked candidates");
|
||||||
|
|
||||||
|
const fallbackLinkable = attachmentPreviewLinkableFiles(preview);
|
||||||
|
assert(fallbackLinkable.length === 11, "linkable fallback contains every unique unlinked candidate");
|
||||||
|
|
||||||
|
const explicitLinkablePreview = {
|
||||||
|
...preview,
|
||||||
|
linkable_files: [files[3], files[3], files[5]]
|
||||||
|
};
|
||||||
|
assert(attachmentPreviewLinkableFiles(explicitLinkablePreview).length === 2, "explicit linkable candidates are deduplicated without a display cap");
|
||||||
|
|
||||||
|
const reviewSource = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8");
|
||||||
|
assert(!reviewSource.includes("attachmentPreviewMatchedFiles(preview).slice("), "review attachment links must not use an arbitrary display slice");
|
||||||
|
assert(reviewSource.includes("<AttachmentLinkingFileList files={matchedFiles} compact />"), "compact review exposes the complete bounded attachment list");
|
||||||
|
assert(reviewSource.includes('aria-haspopup="dialog"'), "the matched count advertises its attachment detail dialog");
|
||||||
|
assert(reviewSource.includes("tabIndex={0}"), "bounded attachment lists are keyboard-focusable scroll regions");
|
||||||
|
assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30"'), "dynamic attachment dialog title uses the i18n message contract");
|
||||||
|
assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951"'), "dynamic attachment count aria label uses the i18n message contract");
|
||||||
|
assert(!reviewSource.includes(">Attachment file links<"), "attachment review heading is not raw English");
|
||||||
|
assert(!reviewSource.includes('label="Matched"'), "attachment review facts are not raw English");
|
||||||
|
assert(!reviewSource.includes("`Link ${unlinkedCount}"), "attachment link actions are not assembled from raw English");
|
||||||
|
assert(!reviewSource.includes("Linked: already part of the campaign file snapshot."), "attachment link-state detail is not raw English");
|
||||||
|
assert(!reviewSource.includes("Unlinked: candidate match, potentially missing until linked."), "unlinked attachment detail is not raw English");
|
||||||
|
|
||||||
|
const newTranslations = [
|
||||||
|
["i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5", "All unique managed files matched by the current attachment rules are available in this list.", "Diese Liste enthält alle eindeutigen verwalteten Dateien, die den aktuellen Anhangsregeln entsprechen."],
|
||||||
|
["i18n:govoplan-campaign.attachment_file_links.0be74fd1", "Attachment file links", "Verknüpfungen von Anhangsdateien"],
|
||||||
|
["i18n:govoplan-campaign.attachment_file_links_value.ce230e30", "Attachment file links ({value0})", "Verknüpfungen von Anhangsdateien ({value0})"],
|
||||||
|
["i18n:govoplan-campaign.link_value_file.4d4ce740", "Link {value0} file", "{value0} Datei verknüpfen"],
|
||||||
|
["i18n:govoplan-campaign.link_value_files.88b7e6a7", "Link {value0} files", "{value0} Dateien verknüpfen"],
|
||||||
|
["i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc", "Linked: already part of the campaign file snapshot.", "Verknüpft: bereits Teil des Kampagnen-Datei-Snapshots."],
|
||||||
|
["i18n:govoplan-campaign.linking.a5f54e0f", "Linking", "Wird verknüpft"],
|
||||||
|
["i18n:govoplan-campaign.matched.1bf3ec5b", "Matched", "Treffer"],
|
||||||
|
["i18n:govoplan-campaign.need_link.fa4ab530", "Need link", "Zu verknüpfen"],
|
||||||
|
["i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c", "No managed files matched the current attachment patterns.", "Keine verwalteten Dateien entsprechen den aktuellen Anhangsmustern."],
|
||||||
|
["i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af", "Preview includes already linked files and accessible unlinked candidates. Locking can link candidates before validation.", "Die Vorschau enthält bereits verknüpfte Dateien und zugängliche, noch nicht verknüpfte Kandidaten. Beim Sperren können Kandidaten vor der Validierung verknüpft werden."],
|
||||||
|
["i18n:govoplan-campaign.refreshing.505dddc9", "Refreshing", "Wird aktualisiert"],
|
||||||
|
["i18n:govoplan-campaign.shared_source.7d4a1bf2", "Shared source", "Gemeinsame Quelle"],
|
||||||
|
["i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998", "Unlinked candidate files are not yet part of the campaign snapshot. They will be linked when you confirm locking.", "Nicht verknüpfte Kandidatendateien sind noch nicht Teil des Kampagnen-Snapshots. Sie werden verknüpft, wenn Sie das Sperren bestätigen."],
|
||||||
|
["i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433", "Unlinked: candidate match, potentially missing until linked.", "Nicht verknüpft: möglicher Treffer, der ohne Verknüpfung fehlen kann."],
|
||||||
|
["i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824", "{value0} matched attachment file link", "{value0} gefundene Verknüpfung einer Anhangsdatei"],
|
||||||
|
["i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a", "{value0} matched attachment file links", "{value0} gefundene Verknüpfungen von Anhangsdateien"],
|
||||||
|
["i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951", "View all {value0} matched attachment file links", "Alle {value0} gefundenen Verknüpfungen von Anhangsdateien anzeigen"]
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8");
|
||||||
|
for (const [key, english, german] of newTranslations) {
|
||||||
|
const digest = createHash("sha1").update(english).digest("hex").slice(0, 8);
|
||||||
|
assert(key.endsWith(`.${digest}`), `${key} uses the stable SHA-1 suffix for its English source`);
|
||||||
|
assert(translationCatalog.split(`"${key}"`).length - 1 === 2, `${key} is present exactly once in both language catalogs`);
|
||||||
|
assert(translationCatalog.includes(`"${key}": ${JSON.stringify(english)}`), `${key} has its English catalog value`);
|
||||||
|
assert(translationCatalog.includes(`"${key}": ${JSON.stringify(german)}`), `${key} has its German catalog value`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlaySource = readFileSync("src/features/campaigns/components/MessagePreviewOverlay.tsx", "utf8");
|
||||||
|
assert(overlaySource.includes("message-preview-backdrop"), "message previews expose a layout-specific responsive backdrop hook");
|
||||||
|
assert(overlaySource.includes("{actions && <div"), "built-message footer actions remain in the stable shared preview footer");
|
||||||
|
assert(overlaySource.includes('aria-label={closeLabel}'), "the preview close control has an accessible label");
|
||||||
|
|
||||||
|
const styles = readFileSync("src/styles/campaign-workspace.css", "utf8");
|
||||||
|
assert(styles.includes("height: min(780px, calc(100dvh - 48px));"), "desktop previews use a stable responsive height");
|
||||||
|
assert(styles.includes("height: calc(100dvh - 16px);"), "small viewports use the available dynamic viewport height");
|
||||||
|
assert(styles.includes(".message-preview-modal .modal-footer"), "preview footer has a stable layout rule");
|
||||||
|
assert(styles.includes(".attachment-linking-file-list.is-compact"), "compact attachment links use a bounded scroll surface");
|
||||||
|
assert(styles.includes(".attachment-linking-file-list.is-detail"), "attachment detail links use an independent scroll surface");
|
||||||
20
webui/tsconfig.review-preview-tests.json
Normal file
20
webui/tsconfig.review-preview-tests.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": ".review-preview-test-build",
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"tests/review-preview-ui.test.ts",
|
||||||
|
"src/features/campaigns/utils/attachmentPreview.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user