Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -29,7 +29,7 @@ import tomllib
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterator, Mapping, Protocol
|
from typing import Any, Callable, Iterator, Mapping, Protocol
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -66,6 +66,7 @@ SEND_RESULT_STATUSES = frozenset(
|
|||||||
{
|
{
|
||||||
"already_accepted",
|
"already_accepted",
|
||||||
"already_claimed",
|
"already_claimed",
|
||||||
|
"already_sending",
|
||||||
"cancelled",
|
"cancelled",
|
||||||
"dry_run",
|
"dry_run",
|
||||||
"failed",
|
"failed",
|
||||||
@@ -135,11 +136,21 @@ SMTP_FAULT_MODES = frozenset(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
WORKER_TASK_CODE = """
|
WORKER_TASK_CODE = """
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from govoplan_core.celery_app import send_email
|
from types import SimpleNamespace
|
||||||
|
from govoplan_core.celery_app import send_email, _worker_runtime_identity
|
||||||
|
from govoplan_core.core.runtime_coordination import register_runtime_node
|
||||||
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
|
identity = _worker_runtime_identity(SimpleNamespace(hostname=f"campaign-acceptance-{os.getpid()}"))
|
||||||
|
with get_database().SessionLocal() as session:
|
||||||
|
register_runtime_node(session, identity, metadata={"acceptance_worker_pid": os.getpid()})
|
||||||
|
session.commit()
|
||||||
|
|
||||||
result = send_email.run(sys.argv[1])
|
result = send_email.run(sys.argv[1])
|
||||||
if not isinstance(result, dict) or result.get("status") not in {
|
if not isinstance(result, dict) or result.get("status") not in {
|
||||||
|
"already_sending",
|
||||||
"outcome_unknown",
|
"outcome_unknown",
|
||||||
"smtp_accepted",
|
"smtp_accepted",
|
||||||
}:
|
}:
|
||||||
@@ -1206,6 +1217,92 @@ def _wait_for_worker_process(process: subprocess.Popen[bytes], *, timeout_second
|
|||||||
raise AcceptanceError("Restarted Campaign worker task failed")
|
raise AcceptanceError("Restarted Campaign worker task failed")
|
||||||
|
|
||||||
|
|
||||||
|
def recover_stopped_fixture_claim(
|
||||||
|
client: ApiClient, headers: Mapping[str, str], *, database: Any,
|
||||||
|
runtime_root: Path, campaign_id: str, version_id: str,
|
||||||
|
stopped_process: subprocess.Popen[bytes],
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
"""Model supervisor proof ONLY in this runner's disposable SQLite fixture.
|
||||||
|
|
||||||
|
No job, attempt or recovery-operation state is edited here. After proving
|
||||||
|
the exact fixture process exited, expire only its lease and mark its own
|
||||||
|
runtime stopped. The real fenced HTTP action performs domain recovery.
|
||||||
|
"""
|
||||||
|
from sqlalchemy.engine import make_url
|
||||||
|
from sqlalchemy.exc import ArgumentError
|
||||||
|
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeNode, process_runtime_identity
|
||||||
|
from govoplan_campaign.backend.db.models import CampaignJob
|
||||||
|
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
|
||||||
|
|
||||||
|
root = runtime_root.resolve()
|
||||||
|
expected_database = root / "acceptance.db"
|
||||||
|
allowed_parents = {Path(tempfile.gettempdir()).resolve(), Path("/tmp").resolve()}
|
||||||
|
if (
|
||||||
|
os.environ.get("APP_ENV") != "test"
|
||||||
|
or root.parent not in allowed_parents
|
||||||
|
or not root.name.startswith(("govoplan-campaign-greenmail-", "govoplan-campaign-celery-redelivery-"))
|
||||||
|
or not expected_database.is_file() or expected_database.is_symlink()
|
||||||
|
or database.engine.url.get_backend_name() != "sqlite"
|
||||||
|
or not database.engine.url.database
|
||||||
|
or Path(database.engine.url.database).resolve() != expected_database
|
||||||
|
):
|
||||||
|
raise AcceptanceError("Claim proof is restricted to the runner's isolated temporary SQLite fixture")
|
||||||
|
try:
|
||||||
|
environment_url = make_url(os.environ.get("DATABASE_URL", ""))
|
||||||
|
except ArgumentError:
|
||||||
|
raise AcceptanceError("Claim proof requires the isolated fixture database environment") from None
|
||||||
|
if (
|
||||||
|
environment_url.get_backend_name() != "sqlite" or not environment_url.database
|
||||||
|
or Path(environment_url.database).resolve() != expected_database
|
||||||
|
):
|
||||||
|
raise AcceptanceError("Claim proof database does not match the isolated fixture environment")
|
||||||
|
if stopped_process.poll() is None or stopped_process.returncode is None:
|
||||||
|
raise AcceptanceError("Claim proof requires the exact fixture worker to have exited")
|
||||||
|
identity = process_runtime_identity()
|
||||||
|
with database.SessionLocal() as session:
|
||||||
|
jobs = session.query(CampaignJob).filter(
|
||||||
|
CampaignJob.campaign_id == campaign_id,
|
||||||
|
CampaignJob.campaign_version_id == version_id,
|
||||||
|
).all()
|
||||||
|
if len(jobs) != 1 or jobs[0].send_status != "sending" or not jobs[0].claim_token:
|
||||||
|
raise AcceptanceError("Claim proof requires exactly one unfinished fixture send")
|
||||||
|
job = jobs[0]
|
||||||
|
lease = session.query(DistributedLease).filter(
|
||||||
|
DistributedLease.installation_id == identity.installation_id,
|
||||||
|
DistributedLease.resource_key == f"campaign:delivery:{job.tenant_id}:{job.id}",
|
||||||
|
).one_or_none()
|
||||||
|
node = session.query(RuntimeNode).filter(
|
||||||
|
RuntimeNode.installation_id == identity.installation_id,
|
||||||
|
RuntimeNode.node_id == lease.holder_node_id,
|
||||||
|
).one_or_none() if lease else None
|
||||||
|
if (
|
||||||
|
lease is None or node is None or node.incarnation != lease.holder_incarnation
|
||||||
|
or (node.metadata_ or {}).get("acceptance_worker_pid") != stopped_process.pid
|
||||||
|
):
|
||||||
|
raise AcceptanceError("Stopped fixture process does not own this exact runtime claim")
|
||||||
|
node.state = "stopped"
|
||||||
|
node.stopped_at = datetime.now(timezone.utc)
|
||||||
|
lease.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||||
|
session.commit()
|
||||||
|
metadata = job_recovery_metadata(session, [job])[job.id]["smtp"]
|
||||||
|
if not metadata["eligible"]:
|
||||||
|
raise AcceptanceError("Proven stopped fixture claim is not eligible for fenced recovery")
|
||||||
|
job_id = job.id
|
||||||
|
revision = metadata["revision"]
|
||||||
|
payload = _expect(client.post(
|
||||||
|
f"/api/v1/campaigns/{campaign_id}/jobs/{job_id}/recover-claim",
|
||||||
|
headers=dict(headers),
|
||||||
|
json={
|
||||||
|
"channel": "smtp", "expected_revision": revision,
|
||||||
|
"note": "Verified isolated fixture worker exited after DATA; expired its test lease. Mailbox/provider reconciliation is still required.",
|
||||||
|
},
|
||||||
|
), 200, "Explicit fenced fixture claim recovery")
|
||||||
|
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||||
|
if result.get("send_status") != "outcome_unknown" or result.get("reconciliation_required") is not True:
|
||||||
|
raise AcceptanceError("Fenced recovery did not preserve the unknown SMTP outcome")
|
||||||
|
return {"stopped_process_verified": True, "fixture_lease_expired": True, "explicit_fenced_recovery": True}
|
||||||
|
|
||||||
|
|
||||||
def execute_worker_interruption_scenario(
|
def execute_worker_interruption_scenario(
|
||||||
client: ApiClient,
|
client: ApiClient,
|
||||||
headers: Mapping[str, str],
|
headers: Mapping[str, str],
|
||||||
@@ -1218,6 +1315,7 @@ def execute_worker_interruption_scenario(
|
|||||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||||
worker_job_probe: Callable[[str], str],
|
worker_job_probe: Callable[[str], str],
|
||||||
|
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
prepared = prepare_campaign_scenario(
|
prepared = prepare_campaign_scenario(
|
||||||
client,
|
client,
|
||||||
@@ -1286,23 +1384,30 @@ def execute_worker_interruption_scenario(
|
|||||||
restarted_state = _durable_state_evidence(
|
restarted_state = _durable_state_evidence(
|
||||||
delivery_probe(prepared.campaign_id, prepared.version_id)
|
delivery_probe(prepared.campaign_id, prepared.version_id)
|
||||||
)
|
)
|
||||||
expected_restarted_state = {
|
if restarted_state != interrupted_state:
|
||||||
|
raise AcceptanceError("Duplicate task changed an unfinished SMTP attempt without stopped-runtime proof")
|
||||||
|
|
||||||
|
protocol_evidence = endpoint.evidence()
|
||||||
|
expected_protocol = {
|
||||||
|
"connection_count": 1,
|
||||||
|
"accepted_rcpt_commands": 1,
|
||||||
|
"refused_rcpt_commands": 0,
|
||||||
|
"data_transactions": 1,
|
||||||
|
}
|
||||||
|
if protocol_evidence != expected_protocol:
|
||||||
|
raise AcceptanceError("Restarted worker contacted SMTP or produced an unexpected transaction")
|
||||||
|
recovery_evidence = dict(recover_claim(prepared.campaign_id, prepared.version_id, first_worker))
|
||||||
|
recovered_state = _durable_state_evidence(delivery_probe(prepared.campaign_id, prepared.version_id))
|
||||||
|
expected_recovered_state = {
|
||||||
"job_count": 1,
|
"job_count": 1,
|
||||||
"send_status_counts": {"outcome_unknown": 1},
|
"send_status_counts": {"outcome_unknown": 1},
|
||||||
"attempt_status_counts": {"outcome_unknown": 1},
|
"attempt_status_counts": {"outcome_unknown": 1},
|
||||||
"unfinished_attempt_count": 0,
|
"unfinished_attempt_count": 0,
|
||||||
}
|
}
|
||||||
if restarted_state != expected_restarted_state:
|
if recovered_state != expected_recovered_state:
|
||||||
raise AcceptanceError("Restarted worker did not freeze the unfinished SMTP attempt")
|
raise AcceptanceError("Explicit fenced recovery did not freeze the unfinished SMTP attempt")
|
||||||
|
if endpoint.evidence() != expected_protocol:
|
||||||
protocol_evidence = endpoint.evidence()
|
raise AcceptanceError("Explicit claim recovery contacted SMTP")
|
||||||
if protocol_evidence != {
|
|
||||||
"connection_count": 1,
|
|
||||||
"accepted_rcpt_commands": 1,
|
|
||||||
"refused_rcpt_commands": 0,
|
|
||||||
"data_transactions": 1,
|
|
||||||
}:
|
|
||||||
raise AcceptanceError("Restarted worker contacted SMTP or produced an unexpected transaction")
|
|
||||||
|
|
||||||
report = _expect(
|
report = _expect(
|
||||||
client.get(
|
client.get(
|
||||||
@@ -1333,12 +1438,15 @@ def execute_worker_interruption_scenario(
|
|||||||
"queue": queue_evidence,
|
"queue": queue_evidence,
|
||||||
"interrupted_durable_state": interrupted_state,
|
"interrupted_durable_state": interrupted_state,
|
||||||
"restarted_durable_state": restarted_state,
|
"restarted_durable_state": restarted_state,
|
||||||
|
"recovered_durable_state": recovered_state,
|
||||||
|
"claim_recovery": recovery_evidence,
|
||||||
"protocol": protocol_evidence,
|
"protocol": protocol_evidence,
|
||||||
"report": report_evidence,
|
"report": report_evidence,
|
||||||
"audit_actions": audit_actions,
|
"audit_actions": audit_actions,
|
||||||
"process_boundary": {
|
"process_boundary": {
|
||||||
"dedicated_task_process_terminated_after_data": True,
|
"dedicated_task_process_terminated_after_data": True,
|
||||||
"fresh_task_process_completed": True,
|
"fresh_task_process_completed": True,
|
||||||
|
"duplicate_task_left_sending_unchanged": True,
|
||||||
"duplicate_smtp_transaction_prevented": True,
|
"duplicate_smtp_transaction_prevented": True,
|
||||||
"celery_broker_redelivery_exercised": False,
|
"celery_broker_redelivery_exercised": False,
|
||||||
},
|
},
|
||||||
@@ -1355,6 +1463,7 @@ def run_acceptance(
|
|||||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||||
worker_job_probe: Callable[[str], str],
|
worker_job_probe: Callable[[str], str],
|
||||||
|
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||||
include_failure_drills: bool,
|
include_failure_drills: bool,
|
||||||
module_versions: Mapping[str, str],
|
module_versions: Mapping[str, str],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -1619,6 +1728,7 @@ def run_acceptance(
|
|||||||
audit_probe=audit_probe,
|
audit_probe=audit_probe,
|
||||||
delivery_probe=delivery_probe,
|
delivery_probe=delivery_probe,
|
||||||
worker_job_probe=worker_job_probe,
|
worker_job_probe=worker_job_probe,
|
||||||
|
recover_claim=recover_claim,
|
||||||
)
|
)
|
||||||
drills["worker_interruption"] = worker_interruption
|
drills["worker_interruption"] = worker_interruption
|
||||||
|
|
||||||
@@ -1647,6 +1757,8 @@ def run_acceptance(
|
|||||||
"post_data_connection_loss_outcome_unknown": include_failure_drills,
|
"post_data_connection_loss_outcome_unknown": include_failure_drills,
|
||||||
"source_artifact_provenance": False,
|
"source_artifact_provenance": False,
|
||||||
"worker_restart_interruption": include_failure_drills,
|
"worker_restart_interruption": include_failure_drills,
|
||||||
|
"duplicate_worker_leaves_active_claim_unchanged": include_failure_drills,
|
||||||
|
"explicit_stopped_runtime_fenced_recovery": include_failure_drills,
|
||||||
"celery_broker_redelivery": False,
|
"celery_broker_redelivery": False,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1888,6 +2000,11 @@ def _bootstrap_and_run(
|
|||||||
audit_probe=audit_probe,
|
audit_probe=audit_probe,
|
||||||
delivery_probe=delivery_probe,
|
delivery_probe=delivery_probe,
|
||||||
worker_job_probe=worker_job_probe,
|
worker_job_probe=worker_job_probe,
|
||||||
|
recover_claim=lambda campaign_id, version_id, process: recover_stopped_fixture_claim(
|
||||||
|
client, {"Authorization": f"Bearer {access_token}"}, database=database,
|
||||||
|
runtime_root=runtime_root, campaign_id=campaign_id, version_id=version_id,
|
||||||
|
stopped_process=process,
|
||||||
|
),
|
||||||
include_failure_drills=include_failure_drills,
|
include_failure_drills=include_failure_drills,
|
||||||
module_versions=module_versions,
|
module_versions=module_versions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ The default run starts an isolated Redis Compose service, two successive real
|
|||||||
Celery worker processes, and a controlled loopback SMTP endpoint. It kills the
|
Celery worker processes, and a controlled loopback SMTP endpoint. It kills the
|
||||||
first worker after complete DATA but before a final SMTP response. The same
|
first worker after complete DATA but before a final SMTP response. The same
|
||||||
unacknowledged broker task must be delivered to the replacement worker, which
|
unacknowledged broker task must be delivered to the replacement worker, which
|
||||||
must freeze the unfinished durable attempt as ``outcome_unknown`` without a
|
must leave the unfinished durable attempt unchanged without a second SMTP
|
||||||
second SMTP connection or DATA transaction.
|
connection or DATA transaction. Only then does explicit fenced recovery use
|
||||||
|
verified process exit and an expired fixture lease to record outcome-unknown.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -52,6 +53,7 @@ from run_campaign_acceptance import ( # noqa: E402
|
|||||||
create_mail_profile,
|
create_mail_profile,
|
||||||
prepare_campaign_scenario,
|
prepare_campaign_scenario,
|
||||||
required_composition_versions,
|
required_composition_versions,
|
||||||
|
recover_stopped_fixture_claim,
|
||||||
smtp_fault_endpoint,
|
smtp_fault_endpoint,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,6 +74,13 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from govoplan_core.celery_app import celery
|
from govoplan_core.celery_app import celery
|
||||||
|
from govoplan_core import celery_app as worker_runtime
|
||||||
|
|
||||||
|
# Test-only process identity evidence, confined to this disposable child.
|
||||||
|
original_worker_metadata = worker_runtime._worker_metadata
|
||||||
|
worker_runtime._worker_metadata = lambda: {
|
||||||
|
**original_worker_metadata(), "acceptance_worker_pid": os.getpid(),
|
||||||
|
}
|
||||||
|
|
||||||
visibility_timeout = int(os.environ["GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS"])
|
visibility_timeout = int(os.environ["GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS"])
|
||||||
celery.conf.broker_transport_options = {
|
celery.conf.broker_transport_options = {
|
||||||
@@ -408,6 +417,7 @@ def execute_redelivery_scenario(
|
|||||||
snapshot_probe: Callable[[str], tuple[Mapping[str, Any], Mapping[str, Any]]],
|
snapshot_probe: Callable[[str], tuple[Mapping[str, Any], Mapping[str, Any]]],
|
||||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||||
|
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
profile_id = create_mail_profile(
|
profile_id = create_mail_profile(
|
||||||
client,
|
client,
|
||||||
@@ -488,9 +498,23 @@ def execute_redelivery_scenario(
|
|||||||
task_id=redelivered_task_id,
|
task_id=redelivered_task_id,
|
||||||
timeout_seconds=settings.provider_timeout_seconds,
|
timeout_seconds=settings.provider_timeout_seconds,
|
||||||
)
|
)
|
||||||
recovered_state = _durable_state_evidence(
|
redelivered_state = _durable_state_evidence(
|
||||||
delivery_probe(prepared.campaign_id, prepared.version_id)
|
delivery_probe(prepared.campaign_id, prepared.version_id)
|
||||||
)
|
)
|
||||||
|
if redelivered_state != interrupted_state:
|
||||||
|
raise AcceptanceError("Redelivered task changed an unfinished attempt without stopped-runtime proof")
|
||||||
|
expected_protocol = {
|
||||||
|
"connection_count": 1,
|
||||||
|
"accepted_rcpt_commands": 1,
|
||||||
|
"refused_rcpt_commands": 0,
|
||||||
|
"data_transactions": 1,
|
||||||
|
}
|
||||||
|
if endpoint.evidence() != expected_protocol:
|
||||||
|
raise AcceptanceError("Broker redelivery caused an unexpected SMTP transaction")
|
||||||
|
recovery_evidence = dict(recover_claim(
|
||||||
|
prepared.campaign_id, prepared.version_id, first_worker.process,
|
||||||
|
))
|
||||||
|
recovered_state = _durable_state_evidence(delivery_probe(prepared.campaign_id, prepared.version_id))
|
||||||
expected_recovered = {
|
expected_recovered = {
|
||||||
"job_count": 1,
|
"job_count": 1,
|
||||||
"send_status_counts": {"outcome_unknown": 1},
|
"send_status_counts": {"outcome_unknown": 1},
|
||||||
@@ -498,17 +522,11 @@ def execute_redelivery_scenario(
|
|||||||
"unfinished_attempt_count": 0,
|
"unfinished_attempt_count": 0,
|
||||||
}
|
}
|
||||||
if recovered_state != expected_recovered:
|
if recovered_state != expected_recovered:
|
||||||
raise AcceptanceError("Redelivered task did not freeze the unfinished attempt")
|
raise AcceptanceError("Explicit fenced recovery did not freeze the unfinished attempt")
|
||||||
|
|
||||||
protocol = endpoint.evidence()
|
protocol = endpoint.evidence()
|
||||||
expected_protocol = {
|
|
||||||
"connection_count": 1,
|
|
||||||
"accepted_rcpt_commands": 1,
|
|
||||||
"refused_rcpt_commands": 0,
|
|
||||||
"data_transactions": 1,
|
|
||||||
}
|
|
||||||
if protocol != expected_protocol:
|
if protocol != expected_protocol:
|
||||||
raise AcceptanceError("Broker redelivery caused an unexpected SMTP transaction")
|
raise AcceptanceError("Explicit claim recovery caused an unexpected SMTP transaction")
|
||||||
broker_after = _wait_for_broker_drained(
|
broker_after = _wait_for_broker_drained(
|
||||||
redis_url,
|
redis_url,
|
||||||
timeout_seconds=settings.provider_timeout_seconds,
|
timeout_seconds=settings.provider_timeout_seconds,
|
||||||
@@ -549,7 +567,9 @@ def execute_redelivery_scenario(
|
|||||||
**prepared.public_evidence(),
|
**prepared.public_evidence(),
|
||||||
"queue": queue_evidence,
|
"queue": queue_evidence,
|
||||||
"interrupted_durable_state": interrupted_state,
|
"interrupted_durable_state": interrupted_state,
|
||||||
|
"redelivered_durable_state": redelivered_state,
|
||||||
"recovered_durable_state": recovered_state,
|
"recovered_durable_state": recovered_state,
|
||||||
|
"claim_recovery": recovery_evidence,
|
||||||
"protocol": protocol,
|
"protocol": protocol,
|
||||||
"report": report,
|
"report": report,
|
||||||
"audit_actions": audit_actions,
|
"audit_actions": audit_actions,
|
||||||
@@ -566,6 +586,7 @@ def execute_redelivery_scenario(
|
|||||||
"first_worker_forced_exit": first_exit_code != 0,
|
"first_worker_forced_exit": first_exit_code != 0,
|
||||||
"replacement_worker_started": True,
|
"replacement_worker_started": True,
|
||||||
"replacement_worker_completed_redelivery": True,
|
"replacement_worker_completed_redelivery": True,
|
||||||
|
"duplicate_task_left_sending_unchanged": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
@@ -743,6 +764,11 @@ def _bootstrap_and_run(
|
|||||||
snapshot_probe=snapshot_probe,
|
snapshot_probe=snapshot_probe,
|
||||||
audit_probe=audit_probe,
|
audit_probe=audit_probe,
|
||||||
delivery_probe=delivery_probe,
|
delivery_probe=delivery_probe,
|
||||||
|
recover_claim=lambda campaign_id, version_id, process: recover_stopped_fixture_claim(
|
||||||
|
client, {"Authorization": f"Bearer {access_token}"}, database=database,
|
||||||
|
runtime_root=runtime_root, campaign_id=campaign_id, version_id=version_id,
|
||||||
|
stopped_process=process,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
evidence = {
|
evidence = {
|
||||||
@@ -766,6 +792,8 @@ def _bootstrap_and_run(
|
|||||||
"celery_worker_processes": True,
|
"celery_worker_processes": True,
|
||||||
"forced_worker_loss_after_complete_data": True,
|
"forced_worker_loss_after_complete_data": True,
|
||||||
"same_task_broker_redelivery": True,
|
"same_task_broker_redelivery": True,
|
||||||
|
"redelivery_leaves_active_claim_unchanged": True,
|
||||||
|
"explicit_stopped_runtime_fenced_recovery": True,
|
||||||
"durable_outcome_unknown_recovery": True,
|
"durable_outcome_unknown_recovery": True,
|
||||||
"duplicate_smtp_transaction_prevented": True,
|
"duplicate_smtp_transaction_prevented": True,
|
||||||
"production_daemon_supervisor": False,
|
"production_daemon_supervisor": False,
|
||||||
|
|||||||
@@ -30,9 +30,24 @@ been validated, built, reviewed, and locked.
|
|||||||
for attempts, outcomes, and reconciliation.
|
for attempts, outcomes, and reconciliation.
|
||||||
- Confirm the effective Send now recipient-job limit. The safe default is 25;
|
- Confirm the effective Send now recipient-job limit. The safe default is 25;
|
||||||
use Queue for workers for ordinary batches or any run above that limit.
|
use Queue for workers for ordinary batches or any run above that limit.
|
||||||
|
System administrators can explicitly configure 0–500 under Administration →
|
||||||
|
SYSTEM → Campaign delivery; an explicit deployment ceiling remains binding,
|
||||||
|
and TENANT policy may only narrow the inherited limit. This setting affects
|
||||||
|
one synchronous request, not campaign size. It is audited and never sends
|
||||||
|
messages; large interactive requests may encounter proxy timeouts.
|
||||||
|
|
||||||
## Deliverability Preflight
|
## Deliverability Preflight
|
||||||
|
|
||||||
|
The Mail server connection test checks that selected server and credential. It
|
||||||
|
does not authorize the campaign's sender, recipients, or resource selection.
|
||||||
|
SMTP runtime checks require the selected SMTP credential when policy forbids
|
||||||
|
inheritance, independently of IMAP. Sent-folder append checks IMAP credentials
|
||||||
|
independently; full campaign validation still checks both required selections.
|
||||||
|
Preflight errors distinguish Mail profile/credential policy, SMTP configuration,
|
||||||
|
authentication, and connectivity. Do not change TLS or credential policies merely
|
||||||
|
because a campaign preflight failed. A preflight rejection leaves staged jobs
|
||||||
|
uncommitted and starts no message delivery.
|
||||||
|
|
||||||
Before the first live send for a sender domain or mail-server profile:
|
Before the first live send for a sender domain or mail-server profile:
|
||||||
|
|
||||||
- Confirm the selected SMTP identity matches the visible From/envelope sender
|
- Confirm the selected SMTP identity matches the visible From/envelope sender
|
||||||
@@ -50,18 +65,25 @@ Before the first live send for a sender domain or mail-server profile:
|
|||||||
|
|
||||||
## Queue And Send
|
## Queue And Send
|
||||||
|
|
||||||
1. Validate the version with file checks enabled.
|
1. Link all required managed files, then validate the version with file checks
|
||||||
|
enabled. Locking waits for a fresh attachment preview and asks for explicit
|
||||||
|
**Link and lock** confirmation when matches are not linked. A locked version
|
||||||
|
cannot change attachment links: use an editable version and repeat validation,
|
||||||
|
build and review rather than assuming unlinked files were included.
|
||||||
2. Build the version and inspect all blocking review items.
|
2. Build the version and inspect all blocking review items.
|
||||||
3. Queue only after the selected version is the intended immutable execution
|
3. Queue only after the selected version is the intended immutable execution
|
||||||
version. Select **Queue for workers**, then verify the committed and
|
version. Select **Queue for workers**, then verify the committed and
|
||||||
published counts.
|
published counts.
|
||||||
4. Use **Send now** only if the exact eligible count is non-zero and at or below
|
4. Use **Send now** only if the exact eligible count is non-zero and at or below
|
||||||
the effective deployment/tenant limit shown on the page.
|
the effective deployment/system/tenant limit shown on the page.
|
||||||
5. In worker mode, verify queue counters move from queued/claimed/sending to a
|
5. In worker mode, verify queue counters move from queued/claimed/sending to a
|
||||||
terminal SMTP state.
|
terminal SMTP state.
|
||||||
6. If a synchronous request is used, keep Review and send open: it polls the
|
6. If a synchronous request is used, keep its blocking progress dialog open.
|
||||||
durable counters while the request runs. A rejection occurs before SMTP and
|
Only its small version-scoped persisted counters refresh; the workspace,
|
||||||
directs oversized runs to workers.
|
recipient list, attachment preview and full summary stay unchanged. Read-only
|
||||||
|
refresh failure retains the last counters and does not prove delivery failed.
|
||||||
|
A disconnected request may still be executing; never repeat it blindly.
|
||||||
|
Oversized initial runs are rejected before SMTP and directed to workers.
|
||||||
7. Review the SMTP batch line. `ready` means DNS/connectivity/TLS/auth preflight
|
7. Review the SMTP batch line. `ready` means DNS/connectivity/TLS/auth preflight
|
||||||
succeeded. Connection and reconnect counts explain reuse. `paused` means a
|
succeeded. Connection and reconnect counts explain reuse. `paused` means a
|
||||||
systemic transport failure stopped the remaining jobs before their SMTP
|
systemic transport failure stopped the remaining jobs before their SMTP
|
||||||
@@ -97,9 +119,10 @@ unknown provider attempt merely to repair the other layer's state.
|
|||||||
unavailable connectivity affect the batch rather than one recipient.
|
unavailable connectivity affect the batch rather than one recipient.
|
||||||
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
||||||
provider control panels, then reconcile as accepted or not sent.
|
provider control panels, then reconcile as accepted or not sent.
|
||||||
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
- `claimed` or `sending` that does not progress: investigate the owning runtime.
|
||||||
Re-run worker handling or reconcile if SMTP may already have accepted the
|
Duplicate worker handling leaves active state unchanged. Never infer from
|
||||||
message.
|
elapsed time alone that SMTP did not accept the message. Use the fenced
|
||||||
|
**Recover interrupted claim** action described below when it is available.
|
||||||
- IMAP `appending`: A worker owns the durable append claim. Do not start a
|
- IMAP `appending`: A worker owns the durable append claim. Do not start a
|
||||||
second append; if the worker cannot finish, reconcile only after checking the
|
second append; if the worker cannot finish, reconcile only after checking the
|
||||||
mailbox.
|
mailbox.
|
||||||
@@ -117,6 +140,65 @@ unknown provider attempt merely to repair the other layer's state.
|
|||||||
- Add a note that identifies the evidence used, for example SMTP log line,
|
- Add a note that identifies the evidence used, for example SMTP log line,
|
||||||
provider message ID, or operator ticket.
|
provider message ID, or operator ticket.
|
||||||
|
|
||||||
|
For pure-Mail SMTP and channel-specific IMAP operations, reconciliation updates
|
||||||
|
the original Campaign attempt, matching Campaign recovery operation and audit
|
||||||
|
record atomically under a fresh lease. A SMTP-only decision cannot resolve an
|
||||||
|
entire compound Mail/Postbox/Print operation; that ledger remains separately
|
||||||
|
unresolved until all of its effects are established. Campaign does
|
||||||
|
not rewrite Mail-owned nested provider-effect operations: those remain Mail's
|
||||||
|
separate evidence and operational responsibility. A failed audit or conflicting
|
||||||
|
claim must leave the prior unknown state intact.
|
||||||
|
|
||||||
|
### Recovery without workers
|
||||||
|
|
||||||
|
The Report offers explicit inline retry and continuation when workers are not
|
||||||
|
configured. Retry uses `campaigns:campaign:retry` plus
|
||||||
|
`campaigns:campaign:send`; continuation uses `campaigns:campaign:queue` plus
|
||||||
|
`campaigns:campaign:send`. Both use the canonical immutable jobs and ordinary
|
||||||
|
attempt ledger, not a separate one-message resend. Current Mail authorization,
|
||||||
|
review/approval, execution integrity, retry limits and rate limits still apply.
|
||||||
|
Each call is bounded by the effective synchronous recipient-job limit and
|
||||||
|
reports remaining eligible work. Continue explicitly until none remains; it
|
||||||
|
never selects accepted, excluded, active, uncertain or known failed jobs.
|
||||||
|
Known failures have their own explicit retry action. These actions do not
|
||||||
|
turn a long HTTP request into a background worker or guarantee exactly-once
|
||||||
|
SMTP when a provider acknowledgement is lost.
|
||||||
|
|
||||||
|
For an abandoned active SMTP or IMAP claim, the report exposes recovery only
|
||||||
|
after the original durable lease expires **and** the runtime registry proves
|
||||||
|
that its owner stopped or was replaced. A stale heartbeat is insufficient.
|
||||||
|
The opaque claim revision and original recovery evidence are rechecked under a
|
||||||
|
fresh lease. Recovery records the effect as **outcome unknown**, never not sent.
|
||||||
|
Then separately inspect external evidence and reconcile with a factual note
|
||||||
|
before any retry. This requires `campaigns:campaign:reconcile`. If the original
|
||||||
|
lease, evidence, or stopped-owner proof is missing, preserve the records and
|
||||||
|
investigate through Ops; do not edit delivery rows or force a lease expiry in
|
||||||
|
a real installation.
|
||||||
|
|
||||||
|
### Progress totals and Sent-folder batching
|
||||||
|
|
||||||
|
For each channel, **processed** includes successful, failed, uncertain and
|
||||||
|
cancelled outcomes. **In progress** is separate from pending, so the currently
|
||||||
|
sending/appending message remains visible. Paused SMTP work is also separate.
|
||||||
|
Excluded/non-requested channel work is outside the denominator. The endpoint
|
||||||
|
requires campaign read and object access and returns no recipient addresses,
|
||||||
|
message bodies, attachments or credentials. It is not the privacy-thresholded
|
||||||
|
aggregate Reports view and does not grant that view recipient access.
|
||||||
|
|
||||||
|
Append-to-Sent targets the selected campaign version, not all historical
|
||||||
|
versions. Each message remains a separately fenced, sequential IMAP APPEND.
|
||||||
|
Mail reuses the authenticated session and resolved folder for at most 100
|
||||||
|
messages or 300 seconds by default, then rotates the connection. Current
|
||||||
|
authorization, frozen transport revisions, credentials and recovery checks
|
||||||
|
still run for every message. A stale idle connection is checked before another
|
||||||
|
APPEND; an uncertain APPEND is never replayed. This removes repeated
|
||||||
|
connect/login/folder-list round trips, not the time needed to upload each EML.
|
||||||
|
It is not an atomic MULTIAPPEND transaction or parallel delivery.
|
||||||
|
|
||||||
|
SMTP and IMAP use the same progress dialog. An acknowledged operation remains
|
||||||
|
successful even if loading its follow-up diagnostics fails: use Reload to
|
||||||
|
refresh display, not to repeat the external effect.
|
||||||
|
|
||||||
## Fault Injection Checklist
|
## Fault Injection Checklist
|
||||||
|
|
||||||
Use mock infrastructure first, then repeat against the non-production real test
|
Use mock infrastructure first, then repeat against the non-production real test
|
||||||
@@ -141,17 +223,21 @@ deliberately excluded.
|
|||||||
|
|
||||||
The runner also terminates a dedicated OS process executing the registered
|
The runner also terminates a dedicated OS process executing the registered
|
||||||
Campaign send task after complete DATA, then invokes the task in a fresh
|
Campaign send task after complete DATA, then invokes the task in a fresh
|
||||||
process. The unfinished durable attempt must become `outcome_unknown` and the
|
process. Redelivery must leave the active claim unchanged and the endpoint
|
||||||
endpoint must observe no second connection or DATA transaction. This covers
|
must observe no second connection or DATA transaction. Only a subsequent
|
||||||
the worker task/process boundary but not a broker or daemon.
|
explicit, fenced recovery with test-fixture stopped-owner and expired-lease
|
||||||
|
proof may change the unfinished attempt to `outcome_unknown`. This covers the
|
||||||
|
worker task/process boundary but not a broker or daemon.
|
||||||
|
|
||||||
Run `dev/mail-testbed/run_celery_redelivery_acceptance.py` for the maintained
|
Run `dev/mail-testbed/run_celery_redelivery_acceptance.py` for the maintained
|
||||||
Redis/Celery delivery and broker redelivery boundary. It starts an isolated
|
Redis/Celery delivery and broker redelivery boundary. It starts an isolated
|
||||||
Redis Compose service and real Celery workers, kills the first solo worker after complete DATA while the
|
Redis Compose service and real Celery workers, kills the first solo worker after complete DATA while the
|
||||||
late-ack task is unacknowledged, and requires the same task identity to reach a
|
late-ack task is unacknowledged, and requires the same task identity to reach a
|
||||||
replacement worker after Redis visibility recovery. Passing evidence also
|
replacement worker after Redis visibility recovery. Passing evidence also
|
||||||
requires durable `outcome_unknown`, an empty broker queue/unacked set, and
|
requires unchanged active state on duplicate delivery, followed by explicit
|
||||||
exactly one SMTP connection and DATA transaction. Raw worker logs and task,
|
fenced recovery to `outcome_unknown`, an empty broker queue/unacked set, and
|
||||||
|
exactly one SMTP connection and DATA transaction. Lease expiration is simulated
|
||||||
|
only in the isolated fixture after its owner was stopped. Raw worker logs and task,
|
||||||
database, endpoint, and credential identifiers are never retained.
|
database, endpoint, and credential identifiers are never retained.
|
||||||
|
|
||||||
That second runner proves local runner-supervised process replacement, not the
|
That second runner proves local runner-supervised process replacement, not the
|
||||||
@@ -190,6 +276,11 @@ and retention.
|
|||||||
|
|
||||||
## Reporting Checks
|
## Reporting Checks
|
||||||
|
|
||||||
|
- The recipient-aware report shows every frozen To/Cc/Bcc address in authored
|
||||||
|
order, with a primary-address fallback only for old rows without that snapshot.
|
||||||
|
SMTP envelope evidence, not the old primary-only UI, establishes how many
|
||||||
|
recipients were actually offered to the provider. SMTP and IMAP diagnostics
|
||||||
|
use list filters and consistent translated labels.
|
||||||
- Partial delivery must show accepted, failed, and unknown counts separately.
|
- Partial delivery must show accepted, failed, and unknown counts separately.
|
||||||
- Excluded messages must show SMTP and IMAP as `skipped`, with skipped counts
|
- Excluded messages must show SMTP and IMAP as `skipped`, with skipped counts
|
||||||
and filters separate from unattempted or failed delivery.
|
and filters separate from unattempted or failed delivery.
|
||||||
|
|||||||
+218
-5
@@ -186,6 +186,11 @@ configured timeout. Opening the link never completes the Workflow.
|
|||||||
password generator keeps its candidate separate from the form until **Use
|
password generator keeps its candidate separate from the form until **Use
|
||||||
password** is explicitly confirmed. Copying a candidate does not save or
|
password** is explicitly confirmed. Copying a candidate does not save or
|
||||||
submit it.
|
submit it.
|
||||||
|
If legacy transport data is reported, choose **Migrate selected Mail
|
||||||
|
profile**, including when the existing profile selection is unchanged.
|
||||||
|
Follow a locked version's supported unlock or editable-successor action
|
||||||
|
before migration. Migration is explicit and audited, never sends mail, and
|
||||||
|
requires validation, build, and review again.
|
||||||
6. Save the editable version, validate the relevant sections, and resolve every
|
6. Save the editable version, validate the relevant sections, and resolve every
|
||||||
blocking issue. Warnings remain explicit review decisions.
|
blocking issue. Warnings remain explicit review decisions.
|
||||||
7. Build the exact messages and inspect recipient, addressing, template,
|
7. Build the exact messages and inspect recipient, addressing, template,
|
||||||
@@ -195,11 +200,77 @@ If a selected optional module is absent, Campaign remains loadable and explains
|
|||||||
which function is unavailable. It must not fail startup because Mail, Files, or
|
which function is unavailable. It must not fail startup because Mail, Files, or
|
||||||
Addresses is not installed.
|
Addresses is not installed.
|
||||||
|
|
||||||
|
Opening or leaving Template without editing does not change the saved HTML or
|
||||||
|
mark the page dirty. Visual/source inspection and read-only changes likewise
|
||||||
|
do not require a save. Actual saves send only client-owned editor metadata. Review
|
||||||
|
and approval evidence remains server-owned and cannot be overwritten by an
|
||||||
|
ordinary editor save. Omitting that readable evidence from a save does not
|
||||||
|
delete it; normal version-lock and invalidation rules remain authoritative.
|
||||||
|
|
||||||
|
### Preserve recipient address order
|
||||||
|
|
||||||
|
In an individual or global address dialog, use the up/down actions to arrange
|
||||||
|
the addresses, then choose **Save** in the dialog. The campaign draft keeps that
|
||||||
|
order; saving no longer alphabetically sorts it. Duplicate email addresses keep
|
||||||
|
their first position, and pasted addresses append in their entered order.
|
||||||
|
The first individual To address is also the primary name/email shown in the
|
||||||
|
recipient row. Use the page's **Save** to persist the campaign draft. A rejected
|
||||||
|
page save keeps the reordered draft for an explicit retry. **Cancel** in the
|
||||||
|
dialog discards only its unconfirmed changes.
|
||||||
|
|
||||||
|
### Permit Legacy ZipCrypto as an explicit compatibility exception
|
||||||
|
|
||||||
|
AES remains the secure default. Campaign **Settings**, **Policies**, and
|
||||||
|
**Attachments** expose the effective archive policy, configuration links for
|
||||||
|
authorized administrators, and **Reload archive policy**.
|
||||||
|
|
||||||
|
1. A policy administrator opens **Administration → SYSTEM → Campaign archive
|
||||||
|
encryption**, enables **Legacy ZipCrypto**, and saves. The controls work
|
||||||
|
before the first system override exists; opening defaults alone does not
|
||||||
|
create an override or unsaved changes. Changing this global ceiling requires
|
||||||
|
both `system:settings:write` and `admin:policies:write`; tenant policy
|
||||||
|
authority alone cannot loosen it.
|
||||||
|
2. Check tenant and owner policy restrictions. Lower scopes may narrow, never
|
||||||
|
loosen, inherited methods and password-delivery channels.
|
||||||
|
3. The Campaign actor also needs `campaigns:archive:use_legacy_zipcrypto` and
|
||||||
|
edit access to the selected version. Policy administration does not replace
|
||||||
|
that dedicated permission.
|
||||||
|
4. Return to Campaign, reload archive policy, and select **Legacy ZipCrypto**
|
||||||
|
under **Attachments → ZIP attachments**. Acknowledge weak encryption, enter
|
||||||
|
an operational reason of at least 10 characters, and select an allowed
|
||||||
|
separate password-delivery channel.
|
||||||
|
5. Save, validate, build, and review. Policy/configuration saves never send
|
||||||
|
mail; delivery remains a separate action.
|
||||||
|
|
||||||
|
Legacy remains blocked while Policy is unavailable. Neither an encryption
|
||||||
|
error nor an incompatible client causes automatic fallback from AES to
|
||||||
|
ZipCrypto. The build retains policy and acknowledgement evidence but never the
|
||||||
|
password; see the manifest topic `campaigns.archive-encryption-governance`.
|
||||||
|
|
||||||
|
Mail migration and ZIP corrections can be saved in either order. A Mail-only
|
||||||
|
migration preserves unchanged ZIP settings without granting permission to use
|
||||||
|
them or adding acknowledgement evidence. An archive correction with unchanged
|
||||||
|
Mail references preserves legacy transport server-side until its separate,
|
||||||
|
explicit migration. Changes to ZIP settings still require the current policy
|
||||||
|
and any dedicated legacy permission; changes to Mail references still require
|
||||||
|
authorized migration. Validate, build and review again after both repairs.
|
||||||
|
|
||||||
|
A save and the following workspace refresh are separate operations. A failed
|
||||||
|
refresh does not undo a committed save or clear the last usable workspace.
|
||||||
|
Keep any newer unsaved edits, inspect the refresh error, and use Reload to fetch
|
||||||
|
the current state. Responses for an earlier campaign, version or signed-in
|
||||||
|
identity cannot overwrite the current workspace.
|
||||||
|
|
||||||
### Review and complete review
|
### Review and complete review
|
||||||
|
|
||||||
The reviewer should verify the immutable candidate that will be delivered, not
|
The reviewer should verify the immutable candidate that will be delivered, not
|
||||||
just the authoring form:
|
just the authoring form:
|
||||||
|
|
||||||
|
If a legacy Mail migration notice appears, follow **Open Mail settings** for
|
||||||
|
that exact version, complete migration, and validate and build again. Review
|
||||||
|
stays read-only until migration is resolved and does not repeatedly request
|
||||||
|
an attachment preview that the legacy transport boundary must reject.
|
||||||
|
|
||||||
1. Confirm purpose, owner, selected version, and recipient count.
|
1. Confirm purpose, owner, selected version, and recipient count.
|
||||||
2. Inspect blocking errors, warnings, exclusions, and recipients requiring
|
2. Inspect blocking errors, warnings, exclusions, and recipients requiring
|
||||||
review.
|
review.
|
||||||
@@ -223,6 +294,67 @@ the required action, the responsible role, and the workspace to open. The
|
|||||||
review summary keeps reviewed and remaining counts visible; a completed review
|
review summary keeps reviewed and remaining counts visible; a completed review
|
||||||
acknowledges the group items and remains bound to the current build token.
|
acknowledges the group items and remains bound to the current build token.
|
||||||
|
|
||||||
|
Save each individual acceptance to persist its reason and reviewed state before
|
||||||
|
completing the entire review. Wait for acknowledgement; reloading then resumes
|
||||||
|
that build's saved progress. If saving fails or conflicts, the pending note
|
||||||
|
remains available for an explicit retry rather than becoming a false success.
|
||||||
|
This small save only loads the selected persisted jobs: it does not rebuild
|
||||||
|
messages, materialize attachments, or reload the whole workspace. Another
|
||||||
|
reviewer's existing decisions and attribution remain intact. Partial progress
|
||||||
|
does not enable delivery; final completion still checks the complete build.
|
||||||
|
|
||||||
|
Use **Accept similar review conditions** to record the same decision for a
|
||||||
|
counted selection of currently loaded matching messages. The server defines
|
||||||
|
eligible categories from the complete combination of overridable conditions;
|
||||||
|
the UI does not interpret a warning badge as permission to override. Select
|
||||||
|
one category, inspect the listed recipients, deselect any exceptions and enter
|
||||||
|
a common reason (required for attachment exceptions). A submission contains
|
||||||
|
at most 200 explicit message IDs. When more remain, save this selection and
|
||||||
|
reopen the dialog; the counts never imply acceptance of unloaded messages or
|
||||||
|
other categories. The reason is recorded separately against each selected
|
||||||
|
message's frozen evidence. A failed save retains the selection and reason for
|
||||||
|
an explicit retry, while a changed build prevents stale acceptance. This
|
||||||
|
action neither sends messages nor completes the final review gate. Hard
|
||||||
|
blockers cannot be accepted this way. Deliberate policy exclusions and
|
||||||
|
attachment rules that explicitly permit zero matches remain informational
|
||||||
|
and do not require review decisions.
|
||||||
|
|
||||||
|
An optional rule with explicit `missing_behavior: continue` may yield no files
|
||||||
|
without creating review work; that outcome remains informational evidence.
|
||||||
|
Required attachment and hard-block policies cannot be weakened by this setting.
|
||||||
|
The separate policy for sending a wholly attachment-free message still applies.
|
||||||
|
Rebuild existing messages after changing attachment policy; historical build
|
||||||
|
evidence is not rewritten.
|
||||||
|
|
||||||
|
The incremental review API uses `merge_progress: true`, the acknowledged
|
||||||
|
`base_revision`, and `build_token` set to the public `review_build_token`.
|
||||||
|
It merges exact reviewed keys/decision job IDs for the current build, with an
|
||||||
|
optional `decision_category_key` to bind a grouped acceptance. The safe review
|
||||||
|
reference is available without diagnostic access; raw build tokens remain
|
||||||
|
diagnostic data. Stale build/revision or simultaneous writes return HTTP 409
|
||||||
|
without overwriting progress. Normal review authorization and audit apply.
|
||||||
|
|
||||||
|
Accepted or expected attachment conditions remain satisfied in **Confirm and
|
||||||
|
send** for the same build. Raw missing/ambiguous source counts remain visible
|
||||||
|
for context; they are not a second approval gate. Reviewed-stage mock delivery
|
||||||
|
uses `use_reviewed_build: true`: it verifies the existing execution seal,
|
||||||
|
completed review, frozen job issues and EML integrity, and current Mail transport
|
||||||
|
before capturing anything in the mock mailbox. It uses those stored messages,
|
||||||
|
not freshly rendered replacements, and never mutates Campaign delivery state.
|
||||||
|
Stale review, changed inputs, changed bytes or changed transport stop the test
|
||||||
|
before captures or requested mailbox clearing. The authoring/mock-preview API
|
||||||
|
keeps its existing transient-build default; `include_needs_review` does not
|
||||||
|
bypass frozen review checks.
|
||||||
|
|
||||||
|
Validation details and repeated-file lists use the shared DataGrid pagination
|
||||||
|
controls so every item is reachable. Related missing-rule causes and their
|
||||||
|
attachment-free policy outcomes appear together, with the technical evidence
|
||||||
|
still expandable. Built messages have four operational states: **Ready**,
|
||||||
|
**Needs review**, **Blocked**, and **Excluded**, plus an explanatory column.
|
||||||
|
Accepted explicit decisions are Ready; warnings awaiting acknowledgment remain
|
||||||
|
Needs review. This presentation does not remove or rewrite frozen issues or
|
||||||
|
audit evidence.
|
||||||
|
|
||||||
This evidence is the Campaign input to separation-of-duties policy. Generic
|
This evidence is the Campaign input to separation-of-duties policy. Generic
|
||||||
approve/reject chains, delegation, substitutions, escalation, and signatures
|
approve/reject chains, delegation, substitutions, escalation, and signatures
|
||||||
belong to the optional Approvals capability. Campaign must not claim an
|
belong to the optional Approvals capability. Campaign must not claim an
|
||||||
@@ -279,7 +411,7 @@ At a minimum:
|
|||||||
ordinary batches; the durable progress remains visible after leaving and
|
ordinary batches; the durable progress remains visible after leaving and
|
||||||
returning to Review and send.
|
returning to Review and send.
|
||||||
2. Use **Send now** only when the exact persisted eligible build is within the
|
2. Use **Send now** only when the exact persisted eligible build is within the
|
||||||
effective synchronous limit shown by the UI. The default deployment limit
|
effective synchronous limit shown by the UI. The unchanged default limit
|
||||||
is 25 recipient jobs. The backend repeats the count and preflights every
|
is 25 recipient jobs. The backend repeats the count and preflights every
|
||||||
message and the Mail profile revision before contacting SMTP.
|
message and the Mail profile revision before contacting SMTP.
|
||||||
3. Treat `smtp_accepted` as protected from ordinary retry.
|
3. Treat `smtp_accepted` as protected from ordinary retry.
|
||||||
@@ -302,10 +434,24 @@ Pause stops new eligible work but cannot undo a provider effect already in
|
|||||||
progress. Cancel marks work that has not yet produced a protected SMTP outcome;
|
progress. Cancel marks work that has not yet produced a protected SMTP outcome;
|
||||||
it cannot recall accepted mail.
|
it cannot recall accepted mail.
|
||||||
|
|
||||||
The deployment ceiling is configured with
|
Configure **Administration → SYSTEM → Campaign delivery** with
|
||||||
`GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0 disables Send now; the
|
`system:settings:read/write`. The default stays 25, but an administrator may
|
||||||
accepted range is 0–500). A tenant may only narrow that ceiling with
|
explicitly choose 0–500, for example 200 for a 183-recipient-job run. Zero disables
|
||||||
`tenant.settings.campaign_delivery_policy.synchronous_send_max_recipients`.
|
Send now. **TENANT → Campaign delivery** uses `admin:policies:read/write` and may
|
||||||
|
only narrow the inherited system policy. Clearing an override restores
|
||||||
|
inheritance. An explicitly configured deployment ceiling
|
||||||
|
`GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` remains authoritative; the
|
||||||
|
implicit default does not prevent a system administrator choosing a larger
|
||||||
|
bounded value. Save changes only this setting, preserves unrelated settings,
|
||||||
|
checks a revision token including inherited policy, and records before/after
|
||||||
|
configuration history and audit. Failed saves retain the draft; conflicting
|
||||||
|
saves require explicit reload/reconciliation. Policy edits never send mail or
|
||||||
|
change existing reviews or approval requirements.
|
||||||
|
|
||||||
|
This limit applies to one interactive Send now request, not campaign size or
|
||||||
|
worker batching. Larger interactive requests run longer and can meet proxy
|
||||||
|
timeouts. Queue for workers is independent and requires enabled, healthy
|
||||||
|
Redis/Celery infrastructure; changing the numeric limit does not start workers.
|
||||||
The effective value and source are returned by the protected delivery-options
|
The effective value and source are returned by the protected delivery-options
|
||||||
API, recorded for successful/rejected synchronous commands, and stated in the
|
API, recorded for successful/rejected synchronous commands, and stated in the
|
||||||
configured handbook topic.
|
configured handbook topic.
|
||||||
@@ -466,6 +612,73 @@ create an editable successor.
|
|||||||
|
|
||||||
## Operations and recovery
|
## Operations and recovery
|
||||||
|
|
||||||
|
### Interactive delivery and Sent-folder progress
|
||||||
|
|
||||||
|
Send now and workerless Report retry/continue use a compact blocking progress
|
||||||
|
dialog, as does inline append-to-Sent. It refreshes saved counters only, not the
|
||||||
|
whole campaign behind the overlay. Successful, pending, in-progress, failed,
|
||||||
|
uncertain and excluded messages are shown separately; a currently sending or
|
||||||
|
appending message therefore does not disappear between totals. Read errors keep
|
||||||
|
the last known counters. After a connection interruption, processing may still
|
||||||
|
be running; inspect saved evidence before repeating any action. A successful
|
||||||
|
write is not reclassified as failed when its later display refresh fails.
|
||||||
|
|
||||||
|
The recipient-aware Report shows all frozen To, Cc and Bcc addresses, not only
|
||||||
|
the primary row identity. Address order and recipient-read authorization remain
|
||||||
|
unchanged. SMTP/IMAP diagnostics use translated status-list filters.
|
||||||
|
|
||||||
|
Without workers, explicitly retry eligible failures or continue unattempted
|
||||||
|
jobs through Report. Each request uses the canonical job/attempt recovery
|
||||||
|
boundary and is limited by the effective synchronous policy. Accepted and
|
||||||
|
uncertain SMTP outcomes remain protected. Active abandoned claims require an
|
||||||
|
expired durable lease, proven stopped/replaced owner, current revision and
|
||||||
|
valid original evidence before recovery can mark them unknown. A separate
|
||||||
|
evidence-note reconciliation is required before retrying. A timeout alone is
|
||||||
|
never proof of non-delivery. See the delivery runbook for required permissions
|
||||||
|
and operational limitations.
|
||||||
|
|
||||||
|
Append-to-Sent is scoped to the selected version and reuses a bounded Mail-owned
|
||||||
|
connection/folder resolution (default 100 messages or 300 seconds), while
|
||||||
|
performing one sequential APPEND and all current checks per message. Uncertain
|
||||||
|
appends are never automatically repeated, and repairing Sent never resends SMTP.
|
||||||
|
|
||||||
|
Link required files before locking. Lock and validate waits for attachment
|
||||||
|
matches, rechecks them immediately before locking, and asks for Link and lock
|
||||||
|
confirmation if new unlinked files are found. A locked version cannot acquire
|
||||||
|
new attachment links; use an editable version and validate/build/review again.
|
||||||
|
|
||||||
|
### Fortschritt, Wiederherstellung und Dateiverknüpfungen
|
||||||
|
|
||||||
|
Jetzt senden, synchrone Wiederholung/Fortsetzung im Bericht und Kopieren nach
|
||||||
|
Gesendet verwenden einen kompakten sperrenden Fortschrittsdialog. Nur gespeicherte
|
||||||
|
Zähler werden aktualisiert, nicht der Arbeitsbereich im Hintergrund. Erfolgreich,
|
||||||
|
ausstehend, in Bearbeitung, fehlgeschlagen, ungewiss und ausgeschlossen bleiben
|
||||||
|
getrennt sichtbar. Bei Lesefehlern bleiben die letzten Werte erhalten. Nach einer
|
||||||
|
getrennten Verbindung kann die Verarbeitung weiterlaufen; prüfen Sie Nachweise,
|
||||||
|
bevor Sie erneut handeln. Ein bestätigter Versand wird durch einen nachfolgenden
|
||||||
|
Anzeigefehler nicht nachträglich als fehlgeschlagen dargestellt.
|
||||||
|
|
||||||
|
Der empfängerbezogene Bericht zeigt alle eingefrorenen An-, Cc- und Bcc-Adressen
|
||||||
|
in ihrer Reihenfolge. Leseberechtigungen bleiben unverändert. SMTP und IMAP
|
||||||
|
verwenden übersetzte Zustandslisten zum Filtern.
|
||||||
|
|
||||||
|
Ohne Worker können bekannte Fehler ausdrücklich wiederholt und unversuchte
|
||||||
|
Aufträge begrenzt fortgesetzt werden. Die wirksame synchrone Grenze, gespeicherte
|
||||||
|
Aufträge, Prüfungen, Freigaben und Wiederherstellungsnachweise bleiben verbindlich.
|
||||||
|
Angenommene und ungewisse SMTP-Ergebnisse werden nicht blind wiederholt. Die
|
||||||
|
Wiederherstellung aktiver, verlassener Aufträge benötigt eine abgelaufene Sperre,
|
||||||
|
nachweislich gestoppte/ersetzte Laufzeit und gültige ursprüngliche Nachweise. Sie
|
||||||
|
setzt ausschließlich auf ungewiss; vor Wiederholung sind externe Nachweise und
|
||||||
|
ein getrennter Abgleich mit Notiz erforderlich. Zeitablauf allein genügt nicht.
|
||||||
|
|
||||||
|
Kopieren nach Gesendet betrifft nur die ausgewählte Version. Mail verwendet die
|
||||||
|
Verbindung und Ordnerauflösung begrenzt wieder (Standard: 100 Nachrichten oder
|
||||||
|
300 Sekunden), prüft aber jede Nachricht erneut und führt APPEND nacheinander
|
||||||
|
aus. Ungewisse Ergebnisse werden nicht automatisch wiederholt. Verknüpfen Sie
|
||||||
|
benötigte Dateien vor dem Sperren; eine frische Prüfung fragt bei unverknüpften
|
||||||
|
Treffern nach Verknüpfen und sperren. Gesperrte Versionen benötigen zum Ändern
|
||||||
|
eine bearbeitbare Version mit erneuter Validierung, Build und Prüfung.
|
||||||
|
|
||||||
### Health to observe
|
### Health to observe
|
||||||
|
|
||||||
- database and migration health;
|
- database and migration health;
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ material and does not delete or rewrite the stored audit rows automatically:
|
|||||||
`mail_profile_migration_required` marker.
|
`mail_profile_migration_required` marker.
|
||||||
- validation, build, queue, retry, and delivery fail closed with an actionable
|
- validation, build, queue, retry, and delivery fail closed with an actionable
|
||||||
profile-migration error;
|
profile-migration error;
|
||||||
- unrelated edits cannot silently scrub the legacy fields;
|
- unrelated edits preserve the exact stored legacy server object when the
|
||||||
|
submitted public Mail references are unchanged; they cannot silently scrub,
|
||||||
|
edit, or re-submit legacy fields or credentials;
|
||||||
- an editable version is migrated only through an explicit Mail-settings save
|
- an editable version is migrated only through an explicit Mail-settings save
|
||||||
with an authorized profile; and
|
with an authorized profile; and
|
||||||
- a locked version remains unchanged. Creating its editable successor records
|
- a locked version remains unchanged. Creating its editable successor records
|
||||||
@@ -81,6 +83,63 @@ database as a whole; the product does not define a separate historical-JSON or
|
|||||||
inline-secret recovery workflow. A restored legacy row remains inert and
|
inline-secret recovery workflow. A restored legacy row remains inert and
|
||||||
fail-closed under the same rules.
|
fail-closed under the same rules.
|
||||||
|
|
||||||
|
### Migrate from the Campaign UI
|
||||||
|
|
||||||
|
Open **Mail settings** from the migration notice for the selected version.
|
||||||
|
Select an authorized Mail profile and choose **Migrate selected Mail profile**.
|
||||||
|
The migration action is available even when that profile was already selected
|
||||||
|
and the draft has no other unsaved changes. If the version is locked, first use
|
||||||
|
its supported unlock or editable-successor action; protected source evidence is
|
||||||
|
not rewritten. Reopen the settings and confirm that the migration notice has
|
||||||
|
gone, then validate, build, and review before separately authorizing delivery.
|
||||||
|
Migration itself never sends mail.
|
||||||
|
|
||||||
|
Mail migration and ZIP policy repairs can be saved in either order. An exact,
|
||||||
|
unchanged ZIP configuration does not require a new acknowledgement merely to
|
||||||
|
save a Mail migration, even if the existing ZIP policy or actor's permission is
|
||||||
|
no longer valid. No actor, timestamp, or consent is invented. Any ZIP change
|
||||||
|
still requires the complete current archive policy and, for ZipCrypto, the
|
||||||
|
dedicated permission and reasoned acknowledgement. Conversely, saving an
|
||||||
|
archive correction with unchanged public Mail references retains the exact
|
||||||
|
legacy transport server-side, without using or reauthorizing the old profile.
|
||||||
|
The migration notice remains until the explicit authorized migration succeeds.
|
||||||
|
Changing any Mail profile, server, or credential reference is not an unrelated
|
||||||
|
repair. Inline transport is rejected even if a caller echoes stored values.
|
||||||
|
The same edit-time separation applies after migration: retaining an unchanged
|
||||||
|
Mail selection does not invoke use-policy while saving an unrelated correction,
|
||||||
|
even if a later policy requires an explicit credential. Selecting a new resource
|
||||||
|
or explicitly migrating still checks Mail permission and current policy; actual
|
||||||
|
validation and delivery always recheck them, regardless of save history.
|
||||||
|
Both repair orders invalidate build/execution evidence; validation, build,
|
||||||
|
review and delivery remain blocked until all outstanding conditions are valid.
|
||||||
|
|
||||||
|
Successful saves and subsequent refreshes are separate outcomes. A committed
|
||||||
|
save is not undone by a failed workspace refresh. The workspace retains its
|
||||||
|
last usable same-campaign/version data and displays the refresh error; retry
|
||||||
|
Reload to fetch the current server state. Obsolete responses from an earlier
|
||||||
|
campaign, version, signed-in identity, or reload cannot replace newer data.
|
||||||
|
|
||||||
|
The normal profile selector requests only campaign-authorized profiles. The
|
||||||
|
administrative profile catalogue is requested separately on **Mail policy**;
|
||||||
|
failure or lack of authority there does not empty the usable profile selector.
|
||||||
|
Profile-list errors remain visible next to the affected settings.
|
||||||
|
|
||||||
|
While migration is required, **Review and send** remains read-only and links to
|
||||||
|
the exact version's Mail settings. It does not repeatedly attempt incompatible
|
||||||
|
attachment-preview requests. These UI affordances do not relax backend
|
||||||
|
validation, build, queue, retry, or delivery enforcement.
|
||||||
|
|
||||||
|
### Editor metadata and trusted evidence
|
||||||
|
|
||||||
|
Read responses may contain server-owned `review_send` and `approval_gate`
|
||||||
|
evidence. Ordinary version mutations send only client-owned `created_from`,
|
||||||
|
`field_overrides`, and `opt_ins` editor metadata. The client omits review and
|
||||||
|
approval evidence; the server rejects attempts to write it through an editor
|
||||||
|
mutation and preserves its existing trusted value during metadata updates.
|
||||||
|
Supported unlock, successor-version, and build invalidation rules still remove
|
||||||
|
stale evidence when required. Opening or leaving the Template editor must not
|
||||||
|
require manually deleting server review metadata.
|
||||||
|
|
||||||
## Operator checks
|
## Operator checks
|
||||||
|
|
||||||
Before live delivery, confirm that:
|
Before live delivery, confirm that:
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.27",
|
"version": "0.1.28",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.27"
|
version = "0.1.28"
|
||||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.28",
|
"govoplan-core>=0.1.45",
|
||||||
"jsonschema>=4,<5",
|
"jsonschema>=4,<5",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
|
|||||||
@@ -192,6 +192,16 @@ def stamp_legacy_zipcrypto_acknowledgements(
|
|||||||
if candidate_raw_json is None:
|
if candidate_raw_json is None:
|
||||||
return None, []
|
return None, []
|
||||||
candidate = copy.deepcopy(candidate_raw_json)
|
candidate = copy.deepcopy(candidate_raw_json)
|
||||||
|
current_attachments = current_raw_json.get("attachments")
|
||||||
|
candidate_attachments = candidate.get("attachments")
|
||||||
|
current_zip = current_attachments.get("zip") if isinstance(current_attachments, Mapping) else None
|
||||||
|
candidate_zip = candidate_attachments.get("zip") if isinstance(candidate_attachments, Mapping) else None
|
||||||
|
if json.dumps(current_zip, sort_keys=True) == json.dumps(candidate_zip, sort_keys=True):
|
||||||
|
# Saving an unrelated repair does not authorize use of an existing
|
||||||
|
# archive or invent an acknowledgement. Preserve the exact stored ZIP
|
||||||
|
# configuration, including missing evidence or now-revoked policy.
|
||||||
|
# Build/review/delivery still validate the complete configuration.
|
||||||
|
return candidate, []
|
||||||
current_by_id = {
|
current_by_id = {
|
||||||
str(item.get("id") or index): item
|
str(item.get("id") or index): item
|
||||||
for index, item in enumerate(_archive_configs(current_raw_json))
|
for index, item in enumerate(_archive_configs(current_raw_json))
|
||||||
@@ -243,6 +253,9 @@ def stamp_legacy_zipcrypto_acknowledgements(
|
|||||||
"policy_hash": policy.policy_hash,
|
"policy_hash": policy.policy_hash,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
# Modified archive settings must satisfy the complete current policy, not
|
||||||
|
# only the special ZipCrypto acknowledgement checks above.
|
||||||
|
assert_archive_encryption_allowed(session, campaign, candidate, principal=principal)
|
||||||
return candidate, acknowledgements
|
return candidate, acknowledgements
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -233,15 +233,20 @@ def _missing_policy_decision(
|
|||||||
candidates,
|
candidates,
|
||||||
key=lambda behavior: _MISSING_BEHAVIOR_STRENGTH[behavior],
|
key=lambda behavior: _MISSING_BEHAVIOR_STRENGTH[behavior],
|
||||||
)
|
)
|
||||||
legacy_drop_normalized = configured == Behavior.DROP
|
if Behavior.BLOCK in candidates:
|
||||||
if legacy_drop_normalized:
|
configured = Behavior.BLOCK
|
||||||
configured = Behavior.BLOCK if config.required else Behavior.ASK
|
elif not config.required and config.missing_behavior == Behavior.CONTINUE:
|
||||||
|
# An explicitly optional, allowed-empty rule is an expected outcome,
|
||||||
|
# not an exception to accept. Hard blocking policy still wins above.
|
||||||
|
configured = Behavior.CONTINUE
|
||||||
|
elif Behavior.DROP in candidates:
|
||||||
|
configured = Behavior.DROP
|
||||||
return AttachmentPolicyDecision(
|
return AttachmentPolicyDecision(
|
||||||
requirement_policy=requirement_policy,
|
requirement_policy=requirement_policy,
|
||||||
campaign_policy=campaign_config.attachments.missing_behavior,
|
campaign_policy=campaign_config.attachments.missing_behavior,
|
||||||
rule_policy=config.missing_behavior,
|
rule_policy=config.missing_behavior,
|
||||||
effective_behavior=configured,
|
effective_behavior=configured,
|
||||||
legacy_drop_normalized=legacy_drop_normalized,
|
legacy_drop_normalized=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -410,7 +415,10 @@ def _issue_for_missing(
|
|||||||
) -> AttachmentIssue:
|
) -> AttachmentIssue:
|
||||||
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
|
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
|
||||||
behavior = policy.effective_behavior
|
behavior = policy.effective_behavior
|
||||||
severity = ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
|
severity = (
|
||||||
|
ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else
|
||||||
|
ResolutionSeverity.INFO if behavior in {Behavior.CONTINUE, Behavior.DROP} else ResolutionSeverity.WARNING
|
||||||
|
)
|
||||||
return AttachmentIssue(
|
return AttachmentIssue(
|
||||||
severity=severity,
|
severity=severity,
|
||||||
code=code,
|
code=code,
|
||||||
@@ -434,9 +442,9 @@ def effective_send_without_attachments_behavior(config: CampaignConfig) -> Behav
|
|||||||
configured = config.attachments.send_without_attachments_behavior or (
|
configured = config.attachments.send_without_attachments_behavior or (
|
||||||
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
||||||
)
|
)
|
||||||
# Recipient exclusion must be an explicit reviewed action, not an implicit
|
# Configured exclusion is already an explicit policy decision. It must not
|
||||||
# consequence of a legacy attachment policy value.
|
# be converted into an acceptance prompt that would send the excluded mail.
|
||||||
return Behavior.ASK if configured == Behavior.DROP else configured
|
return configured
|
||||||
|
|
||||||
|
|
||||||
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
|
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
|
||||||
@@ -447,7 +455,7 @@ def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssu
|
|||||||
Behavior.WARN: "No attachment file was resolved for this message. Campaign policy allows sending with a warning.",
|
Behavior.WARN: "No attachment file was resolved for this message. Campaign policy allows sending with a warning.",
|
||||||
}
|
}
|
||||||
return AttachmentIssue(
|
return AttachmentIssue(
|
||||||
severity=ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING,
|
severity=(ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.INFO if behavior == Behavior.DROP else ResolutionSeverity.WARNING),
|
||||||
code="missing_attachment_coverage",
|
code="missing_attachment_coverage",
|
||||||
message=messages.get(behavior, "No attachment file was resolved for this message."),
|
message=messages.get(behavior, "No attachment file was resolved for this message."),
|
||||||
behavior=behavior,
|
behavior=behavior,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +51,12 @@ class CampaignMailProfileBoundaryError(ValueError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_review_reference(version_id: str, build_token: Any) -> str | None:
|
||||||
|
"""A public concurrency reference, not a raw diagnostic/build claim token."""
|
||||||
|
token = str(build_token or "").strip()
|
||||||
|
return hashlib.sha256(f"campaign-review:{version_id}:{token}".encode()).hexdigest() if token else None
|
||||||
|
|
||||||
|
|
||||||
def _validated_opt_ins(value: Any) -> dict[str, bool]:
|
def _validated_opt_ins(value: Any) -> dict[str, bool]:
|
||||||
if not isinstance(value, dict) or any(
|
if not isinstance(value, dict) or any(
|
||||||
key not in CAMPAIGN_OPT_IN_KEYS for key in value
|
key not in CAMPAIGN_OPT_IN_KEYS for key in value
|
||||||
@@ -198,6 +205,24 @@ def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
|
|||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_editor_state_with_client_update(
|
||||||
|
stored: Any, client_state: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Replace client metadata without accepting or erasing server evidence.
|
||||||
|
|
||||||
|
Read responses contain review and approval state, but ordinary editor saves
|
||||||
|
may only supply client-owned fields. Their omission must not delete trusted
|
||||||
|
server evidence; content/build invalidation remains owned by its lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = validate_campaign_editor_state(client_state)
|
||||||
|
server_state = public_campaign_editor_state(stored, include_diagnostics=True)
|
||||||
|
for key in ("review_send", "approval_gate"):
|
||||||
|
if key in server_state:
|
||||||
|
result[key] = server_state[key]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _validated_server_approval_gate(value: Any) -> dict[str, Any]:
|
def _validated_server_approval_gate(value: Any) -> dict[str, Any]:
|
||||||
if not isinstance(value, dict) or any(
|
if not isinstance(value, dict) or any(
|
||||||
key not in CAMPAIGN_APPROVAL_GATE_KEYS for key in value
|
key not in CAMPAIGN_APPROVAL_GATE_KEYS for key in value
|
||||||
@@ -483,3 +508,23 @@ def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, st
|
|||||||
for key, value in campaign_mail_resource_ids(raw_json).items()
|
for key, value in campaign_mail_resource_ids(raw_json).items()
|
||||||
if value
|
if value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_mail_references_unchanged(
|
||||||
|
current: dict[str, Any] | None, candidate: dict[str, Any] | None
|
||||||
|
) -> bool:
|
||||||
|
"""Recognize an unchanged public selection, never client-owned transport."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
isinstance(candidate, dict)
|
||||||
|
and not campaign_mail_profile_boundary_violations(candidate)
|
||||||
|
and candidate.get("server", {}) == public_campaign_mail_server(current)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_preserves_legacy_mail_settings(
|
||||||
|
current: dict[str, Any] | None, candidate: dict[str, Any] | None
|
||||||
|
) -> bool:
|
||||||
|
"""Keep stored legacy transport inert without accepting it from a client."""
|
||||||
|
|
||||||
|
return bool(campaign_mail_profile_boundary_violations(current)) and campaign_mail_references_unchanged(current, candidate)
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ from .models import (
|
|||||||
effective_delivery_channel_policy,
|
effective_delivery_channel_policy,
|
||||||
effective_postbox_targets,
|
effective_postbox_targets,
|
||||||
)
|
)
|
||||||
from ..attachments.resolver import resolve_campaign_attachments
|
|
||||||
|
|
||||||
|
|
||||||
class Severity(StrEnum):
|
class Severity(StrEnum):
|
||||||
@@ -877,6 +876,10 @@ def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -
|
|||||||
|
|
||||||
|
|
||||||
def _attachment_resolution_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
def _attachment_resolution_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||||
|
# The resolver consumes campaign models/entries. Import it only when file
|
||||||
|
# validation is requested so either public entry point can initialize first.
|
||||||
|
from ..attachments.resolver import resolve_campaign_attachments
|
||||||
|
|
||||||
try:
|
try:
|
||||||
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
|
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -540,6 +540,12 @@ class CampaignVersion(Base, TimestampMixin):
|
|||||||
"version_id_col": edit_revision,
|
"version_id_col": edit_revision,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def review_build_token(self) -> str | None:
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_review_reference
|
||||||
|
summary = self.build_summary if isinstance(self.build_summary, dict) else {}
|
||||||
|
return campaign_review_reference(self.id, summary.get("build_token") or summary.get("built_at"))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def strong_etag(self) -> str:
|
def strong_etag(self) -> str:
|
||||||
return strong_resource_etag(
|
return strong_resource_etag(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from typing import Any, Mapping
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.admin.models import SystemSettings
|
||||||
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||||
from govoplan_core.tenancy.scope import Tenant
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ class SynchronousSendPolicy:
|
|||||||
source: str
|
source: str
|
||||||
deployment_max_recipient_jobs: int
|
deployment_max_recipient_jobs: int
|
||||||
tenant_max_recipient_jobs: int | None = None
|
tenant_max_recipient_jobs: int | None = None
|
||||||
|
system_max_recipient_jobs: int | None = None
|
||||||
|
deployment_ceiling_explicit: bool = False
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -33,6 +37,9 @@ class SynchronousSendPolicy:
|
|||||||
"source": self.source,
|
"source": self.source,
|
||||||
"deployment_max_recipient_jobs": self.deployment_max_recipient_jobs,
|
"deployment_max_recipient_jobs": self.deployment_max_recipient_jobs,
|
||||||
"tenant_max_recipient_jobs": self.tenant_max_recipient_jobs,
|
"tenant_max_recipient_jobs": self.tenant_max_recipient_jobs,
|
||||||
|
"system_max_recipient_jobs": self.system_max_recipient_jobs,
|
||||||
|
"deployment_ceiling_explicit": self.deployment_ceiling_explicit,
|
||||||
|
"system_setting": f"system.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}.{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}",
|
||||||
"deployment_setting": SYNCHRONOUS_SEND_MAX_ENV,
|
"deployment_setting": SYNCHRONOUS_SEND_MAX_ENV,
|
||||||
"tenant_setting": (
|
"tenant_setting": (
|
||||||
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
|
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
|
||||||
@@ -46,20 +53,36 @@ def effective_synchronous_send_policy(
|
|||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
environ: Mapping[str, str] | None = None,
|
environ: Mapping[str, str] | None = None,
|
||||||
|
apply_tenant_override: bool = True,
|
||||||
) -> SynchronousSendPolicy:
|
) -> SynchronousSendPolicy:
|
||||||
env = os.environ if environ is None else environ
|
env = os.environ if environ is None else environ
|
||||||
|
deployment_raw = env.get(SYNCHRONOUS_SEND_MAX_ENV)
|
||||||
|
deployment_explicit = deployment_raw is not None and (not isinstance(deployment_raw, str) or bool(deployment_raw.strip()))
|
||||||
deployment_value = _configured_limit(
|
deployment_value = _configured_limit(
|
||||||
env.get(SYNCHRONOUS_SEND_MAX_ENV),
|
env.get(SYNCHRONOUS_SEND_MAX_ENV),
|
||||||
source=SYNCHRONOUS_SEND_MAX_ENV,
|
source=SYNCHRONOUS_SEND_MAX_ENV,
|
||||||
default=DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
default=ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||||
)
|
)
|
||||||
tenant = session.get(Tenant, tenant_id)
|
system = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
|
||||||
|
system_raw = _tenant_limit_value(system.settings if system is not None else None)
|
||||||
|
system_value = _configured_limit(system_raw, source="system campaign delivery policy") if system_raw is not None else None
|
||||||
|
# Preserve explicit deployment configuration, but an implicit default is not
|
||||||
|
# an administrator ceiling. No override still retains the conservative 25.
|
||||||
|
inherited = min(deployment_value, system_value) if system_value is not None else (
|
||||||
|
deployment_value if deployment_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS
|
||||||
|
)
|
||||||
|
inherited_source = ("system" if system_value <= deployment_value else "deployment_ceiling") if system_value is not None else (
|
||||||
|
"deployment" if deployment_explicit else "deployment_default"
|
||||||
|
)
|
||||||
|
tenant = session.get(Tenant, tenant_id) if apply_tenant_override else None
|
||||||
tenant_raw = _tenant_limit_value(tenant.settings if tenant is not None else None)
|
tenant_raw = _tenant_limit_value(tenant.settings if tenant is not None else None)
|
||||||
if tenant_raw is None:
|
if tenant_raw is None:
|
||||||
return SynchronousSendPolicy(
|
return SynchronousSendPolicy(
|
||||||
max_recipient_jobs=deployment_value,
|
max_recipient_jobs=inherited,
|
||||||
source=("deployment" if env.get(SYNCHRONOUS_SEND_MAX_ENV) not in (None, "") else "deployment_default"),
|
source=inherited_source,
|
||||||
deployment_max_recipient_jobs=deployment_value,
|
deployment_max_recipient_jobs=deployment_value,
|
||||||
|
system_max_recipient_jobs=system_value,
|
||||||
|
deployment_ceiling_explicit=deployment_explicit,
|
||||||
)
|
)
|
||||||
|
|
||||||
tenant_value = _configured_limit(
|
tenant_value = _configured_limit(
|
||||||
@@ -69,12 +92,14 @@ def effective_synchronous_send_policy(
|
|||||||
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
|
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
effective_value = min(deployment_value, tenant_value)
|
effective_value = min(inherited, tenant_value)
|
||||||
return SynchronousSendPolicy(
|
return SynchronousSendPolicy(
|
||||||
max_recipient_jobs=effective_value,
|
max_recipient_jobs=effective_value,
|
||||||
source="tenant" if tenant_value <= deployment_value else "deployment_ceiling",
|
source="tenant" if tenant_value <= inherited else ("system_ceiling" if inherited_source == "system" else "deployment_ceiling"),
|
||||||
deployment_max_recipient_jobs=deployment_value,
|
deployment_max_recipient_jobs=deployment_value,
|
||||||
tenant_max_recipient_jobs=tenant_value,
|
tenant_max_recipient_jobs=tenant_value,
|
||||||
|
system_max_recipient_jobs=system_value,
|
||||||
|
deployment_ceiling_explicit=deployment_explicit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from email import policy
|
from email import policy
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
|
||||||
|
from govoplan_campaign.backend.campaign.validation import SemanticReport, validate_campaign_config
|
||||||
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
|
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
|
||||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
from govoplan_campaign.backend.messages.builder import BuiltMessage, CampaignBuildResult, build_campaign_messages
|
||||||
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
|
from govoplan_campaign.backend.messages.models import CampaignBuildReport, MessageAddress, MessageAttachmentSummary, MessageDraft, MessageValidationStatus
|
||||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||||
|
|
||||||
@@ -151,6 +154,7 @@ def _mock_send_batch(
|
|||||||
include_warnings: bool,
|
include_warnings: bool,
|
||||||
include_needs_review: bool,
|
include_needs_review: bool,
|
||||||
append_sent: bool,
|
append_sent: bool,
|
||||||
|
reviewed_keys: set[str] | None = None,
|
||||||
) -> _MockSendBatch:
|
) -> _MockSendBatch:
|
||||||
batch = _MockSendBatch(results=[])
|
batch = _MockSendBatch(results=[])
|
||||||
for built in built_messages:
|
for built in built_messages:
|
||||||
@@ -160,7 +164,7 @@ def _mock_send_batch(
|
|||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
send=send,
|
send=send,
|
||||||
include_warnings=include_warnings,
|
include_warnings=include_warnings,
|
||||||
include_needs_review=include_needs_review,
|
include_needs_review=include_needs_review or str(built.draft.entry_id or built.draft.entry_index) in (reviewed_keys or set()),
|
||||||
append_sent=append_sent,
|
append_sent=append_sent,
|
||||||
)
|
)
|
||||||
batch.results.append(outcome.row)
|
batch.results.append(outcome.row)
|
||||||
@@ -391,6 +395,96 @@ def _build_mock_campaign_run(
|
|||||||
return validation_report, build_result, send_batch
|
return validation_report, build_result, send_batch
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reviewed_mock_run(
|
||||||
|
session: Session, *, tenant_id: str, campaign: Campaign, version: CampaignVersion,
|
||||||
|
mailbox: Any | None, send: bool, include_warnings: bool, append_sent: bool,
|
||||||
|
clear_mailbox: bool = False,
|
||||||
|
) -> tuple[Any, Any, _MockSendBatch]:
|
||||||
|
"""Mock the sealed EML, never approve a new transient rendering by entry ID."""
|
||||||
|
from govoplan_campaign.backend.persistence.versions import _complete_campaign_review
|
||||||
|
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot, profile_delivery_summary
|
||||||
|
from govoplan_campaign.backend.sending.jobs import _load_eml_bytes_for_job
|
||||||
|
|
||||||
|
if not isinstance(version.execution_snapshot, dict) or not version.execution_snapshot_hash:
|
||||||
|
raise MockCampaignSendError("Build the campaign with frozen execution evidence before testing reviewed messages.")
|
||||||
|
# Do not invoke the legacy snapshot-creation fallback: this test must not
|
||||||
|
# alter Campaign state or create approval evidence as a side effect.
|
||||||
|
snapshot = ensure_execution_snapshot(session, version)
|
||||||
|
build = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||||
|
token = str(build.get("build_token") or build.get("built_at") or "")
|
||||||
|
if not token or token != str(snapshot.build_token or snapshot.built_at or ""):
|
||||||
|
raise MockCampaignSendError("The frozen execution no longer matches the current message build. Rebuild and review again.")
|
||||||
|
jobs = session.query(CampaignJob).filter(
|
||||||
|
CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id,
|
||||||
|
).order_by(CampaignJob.entry_index.asc()).all()
|
||||||
|
if not jobs:
|
||||||
|
raise MockCampaignSendError("The reviewed build contains no messages.")
|
||||||
|
state = (version.editor_state or {}).get("review_send", {})
|
||||||
|
complete = isinstance(state, dict) and state.get("inspection_complete") is True and state.get("build_token") == token
|
||||||
|
if any(job.validation_status in {"needs_review", "warning"} for job in jobs) and not complete:
|
||||||
|
raise MockCampaignSendError("Complete review for the exact current build before testing accepted exceptions.")
|
||||||
|
reviewed_keys = list(state.get("reviewed_message_keys", [])) if complete else []
|
||||||
|
decisions = list(state.get("issue_decisions", [])) if complete else []
|
||||||
|
_, normalized = _complete_campaign_review(session, version, reviewed_keys, decisions, user_id=None, build_token=token)
|
||||||
|
by_id = {item.get("job_id"): item for item in decisions}
|
||||||
|
for item in normalized:
|
||||||
|
prior = by_id.get(item["job_id"], {})
|
||||||
|
if any(prior.get(key) != item.get(key) for key in ("build_token", "message_sha256", "issue_fingerprint")):
|
||||||
|
raise MockCampaignSendError("Review evidence no longer matches the frozen message issues. Rebuild and review again.")
|
||||||
|
if snapshot.uses_mail:
|
||||||
|
current = profile_delivery_summary(session, version)
|
||||||
|
if current.get("smtp_transport_revision") != snapshot.smtp_transport_revision or (
|
||||||
|
append_sent and current.get("imap_transport_revision") != snapshot.imap_transport_revision
|
||||||
|
):
|
||||||
|
raise MockCampaignSendError("The selected Mail transport changed after build. Rebuild and review before testing these messages.")
|
||||||
|
|
||||||
|
built_messages = []
|
||||||
|
for job in jobs:
|
||||||
|
recipients = job.resolved_recipients or {}
|
||||||
|
attachments = [MessageAttachmentSummary.model_validate({
|
||||||
|
"status": "missing", "required": False, "allow_multiple": False, "zip_enabled": False,
|
||||||
|
"file_filter": "", "directory": "",
|
||||||
|
**{key: value for key, value in item.items() if key in MessageAttachmentSummary.model_fields},
|
||||||
|
}) for item in (job.resolved_attachments or []) if isinstance(item, dict)]
|
||||||
|
inactive = job.validation_status == "inactive"
|
||||||
|
excluded = job.validation_status == "excluded" or inactive
|
||||||
|
draft = MessageDraft(
|
||||||
|
entry_index=job.entry_index, entry_id=job.entry_id, active=not inactive,
|
||||||
|
build_status="built" if job.build_status == "built" else "build_failed",
|
||||||
|
validation_status=job.validation_status, send_status="skipped" if excluded else "draft",
|
||||||
|
imap_status="skipped" if excluded else "not_requested", subject=job.subject,
|
||||||
|
delivery_channel_policy=job.delivery_channel_policy,
|
||||||
|
**{key: recipients.get(key) for key in ("from",) if recipients.get(key)},
|
||||||
|
**{key: recipients.get(key) or [] for key in ("from_all", "to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to")},
|
||||||
|
issues=job.issues_snapshot or [], attachments=attachments,
|
||||||
|
attachment_count=sum(len(item.managed_matches or item.matches) for item in attachments),
|
||||||
|
eml_size_bytes=job.eml_size_bytes,
|
||||||
|
)
|
||||||
|
mime = None
|
||||||
|
if not excluded and DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail:
|
||||||
|
if not job.eml_sha256:
|
||||||
|
raise MockCampaignSendError("Frozen EML checksum is missing; rebuild before testing reviewed messages.")
|
||||||
|
# The shared reader checks byte length, digest and Message-ID before
|
||||||
|
# any mock capture occurs. All messages preflight before the batch.
|
||||||
|
try:
|
||||||
|
mime = BytesParser(policy=policy.default).parsebytes(_load_eml_bytes_for_job(job))
|
||||||
|
except Exception as exc:
|
||||||
|
raise MockCampaignSendError("Frozen message bytes are unavailable or no longer match their integrity evidence. Rebuild and review before testing.") from exc
|
||||||
|
built_messages.append(BuiltMessage(draft=draft, mime=mime))
|
||||||
|
report = CampaignBuildReport(campaign_id=campaign.external_id, campaign_name=campaign.name,
|
||||||
|
campaign_file="", entries_count=len(jobs), messages=[item.draft for item in built_messages])
|
||||||
|
validation = SemanticReport(campaign_id=campaign.external_id, campaign_name=campaign.name,
|
||||||
|
entries_mode="frozen_build", entries_count=len(jobs), attachments_base_path="", rate_limit="frozen",
|
||||||
|
imap_append_enabled=snapshot.delivery.imap_append_sent.enabled)
|
||||||
|
config = SimpleNamespace(delivery=snapshot.delivery, server=SimpleNamespace(imap=None))
|
||||||
|
if clear_mailbox and mailbox is not None:
|
||||||
|
mailbox.clear_records()
|
||||||
|
batch = _mock_send_batch(config=config, built_messages=built_messages, mailbox=mailbox, send=send,
|
||||||
|
include_warnings=include_warnings, include_needs_review=False, append_sent=append_sent,
|
||||||
|
reviewed_keys=set(reviewed_keys))
|
||||||
|
return validation, CampaignBuildResult(report=report, built_messages=built_messages), batch
|
||||||
|
|
||||||
|
|
||||||
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
|
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
|
||||||
payload = validation_report.model_dump(mode="json")
|
payload = validation_report.model_dump(mode="json")
|
||||||
payload.update(
|
payload.update(
|
||||||
@@ -542,6 +636,7 @@ def run_mock_campaign_send(
|
|||||||
append_sent: bool = True,
|
append_sent: bool = True,
|
||||||
clear_mailbox: bool = False,
|
clear_mailbox: bool = False,
|
||||||
check_files: bool = False,
|
check_files: bool = False,
|
||||||
|
use_reviewed_build: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Validate, build and optionally mock-send a version without mutating it.
|
"""Validate, build and optionally mock-send a version without mutating it.
|
||||||
|
|
||||||
@@ -557,22 +652,29 @@ def run_mock_campaign_send(
|
|||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
)
|
)
|
||||||
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
|
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox and not use_reviewed_build)
|
||||||
validation_report, build_result, send_batch = _build_mock_campaign_run(
|
if use_reviewed_build:
|
||||||
session,
|
validation_report, build_result, send_batch = _build_reviewed_mock_run(
|
||||||
tenant_id=tenant_id,
|
session, tenant_id=tenant_id, campaign=campaign, version=version,
|
||||||
campaign=campaign,
|
mailbox=mailbox, send=send, include_warnings=include_warnings, append_sent=append_sent,
|
||||||
version=version,
|
clear_mailbox=clear_mailbox,
|
||||||
mailbox=mailbox,
|
)
|
||||||
send=send,
|
else:
|
||||||
include_warnings=include_warnings,
|
validation_report, build_result, send_batch = _build_mock_campaign_run(
|
||||||
include_needs_review=include_needs_review,
|
session,
|
||||||
append_sent=append_sent,
|
tenant_id=tenant_id,
|
||||||
check_files=check_files,
|
campaign=campaign,
|
||||||
)
|
version=version,
|
||||||
|
mailbox=mailbox,
|
||||||
|
send=send,
|
||||||
|
include_warnings=include_warnings,
|
||||||
|
include_needs_review=include_needs_review,
|
||||||
|
append_sent=append_sent,
|
||||||
|
check_files=check_files,
|
||||||
|
)
|
||||||
validation_payload = _mock_validation_payload(validation_report)
|
validation_payload = _mock_validation_payload(validation_report)
|
||||||
build_payload = _mock_build_payload(build_result)
|
build_payload = _mock_build_payload(build_result)
|
||||||
return _mock_campaign_send_response(
|
result = _mock_campaign_send_response(
|
||||||
campaign=campaign,
|
campaign=campaign,
|
||||||
version=version,
|
version=version,
|
||||||
mailbox=mailbox,
|
mailbox=mailbox,
|
||||||
@@ -586,3 +688,9 @@ def run_mock_campaign_send(
|
|||||||
include_needs_review=include_needs_review,
|
include_needs_review=include_needs_review,
|
||||||
append_sent=append_sent,
|
append_sent=append_sent,
|
||||||
)
|
)
|
||||||
|
result["use_reviewed_build"] = use_reviewed_build
|
||||||
|
if use_reviewed_build:
|
||||||
|
result["steps"][0].update(label="Verify frozen execution inputs", status="ok")
|
||||||
|
result["steps"][1].update(label="Use reviewed frozen messages", status="ok")
|
||||||
|
result["build"]["review_satisfied"] = True
|
||||||
|
return result
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
|
|||||||
topic_id="campaigns.workflow.create-campaign",
|
topic_id="campaigns.workflow.create-campaign",
|
||||||
title="Create a campaign",
|
title="Create a campaign",
|
||||||
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
|
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
|
||||||
body="A new campaign starts with one editable working version. Campaign editors report saved, unsaved, and saving state in the page action bar; Discard remains immediately before Save, and leaving a dirty draft invokes the shared save-or-discard guard. Destructive campaign lifecycle actions are visually separated from ordinary actions. Creating a campaign does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
|
body="A new campaign starts with one editable working version. Campaign editors report saved, unsaved, and saving state in the page action bar; Discard remains immediately before Save, and leaving a dirty draft invokes the shared save-or-discard guard. Destructive campaign lifecycle actions are visually separated from ordinary actions. Creating a campaign does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized. Saved changes are acknowledged separately from the follow-up refresh. A failed write or cancelled conflict keeps the draft; repeated Save clicks share one pending operation. Typing during a save keeps newer work unsaved instead of overwriting it with the older acknowledgement. A failed or superseded Discard refresh retains the draft. Unchanged ZIP configuration does not disable saving unrelated attachment source or rule edits. Shared attachment Add actions remain compact. If the Files chooser is temporarily unavailable, the editor explains this instead of silently treating a managed path as manual text; missing rule sources must be selected again. Keyboard Enter or Space opens chooser-backed path fields.",
|
||||||
order=30,
|
order=30,
|
||||||
audience=("campaign_manager", "campaign_author"),
|
audience=("campaign_manager", "campaign_author"),
|
||||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
||||||
@@ -558,7 +558,7 @@ CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
|
|||||||
topic_id="campaigns.workflow.use-managed-attachments",
|
topic_id="campaigns.workflow.use-managed-attachments",
|
||||||
title="Use managed files as campaign attachments",
|
title="Use managed files as campaign attachments",
|
||||||
summary="Select governed file versions, preview rule matches, and preserve exactly which files were used for the campaign build.",
|
summary="Select governed file versions, preview rule matches, and preserve exactly which files were used for the campaign build.",
|
||||||
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths.",
|
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths. Link required files before locking. Review and send explains why a locked version cannot change its file links. Locking is unavailable while the preview is loading and rechecks current matches at the action boundary. Newly unlinked matches require explicit Link and lock confirmation; a failed check leaves the version unlocked. Link missing files in an editable version, then validate, build and review again.",
|
||||||
order=34,
|
order=34,
|
||||||
audience=("campaign_manager", "campaign_author"),
|
audience=("campaign_manager", "campaign_author"),
|
||||||
required_modules=("campaigns", "files"),
|
required_modules=("campaigns", "files"),
|
||||||
@@ -743,7 +743,7 @@ CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
|
|||||||
topic_id="campaigns.workflow.send-small-controlled-run",
|
topic_id="campaigns.workflow.send-small-controlled-run",
|
||||||
title="Send a campaign immediately",
|
title="Send a campaign immediately",
|
||||||
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
|
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
|
||||||
body="Send now is protected by an effective deployment/tenant recipient-job maximum. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect.",
|
body="Send now is protected by an effective deployment/system/tenant recipient-job maximum, not a campaign-size limit. The default is 25. Administration → SYSTEM → Campaign delivery can configure 0–500 within any explicit deployment ceiling; TENANT may only narrow the inherited limit. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect. Worker delivery remains independent and requires working background infrastructure. A successful Mail server test proves that connection only, not the campaign's resource selection or sender/recipient authorization. Preflight distinguishes Mail profile/credential policy, SMTP configuration, authentication, and connectivity failures without exposing secret or raw provider details. SMTP checks its own credential selection; IMAP independently checks its selection when appending to Sent. Full campaign validation still checks all required selections.",
|
||||||
order=36,
|
order=36,
|
||||||
audience=("campaign_sender", "campaign_operator"),
|
audience=("campaign_sender", "campaign_operator"),
|
||||||
required_modules=("campaigns", "mail"),
|
required_modules=("campaigns", "mail"),
|
||||||
@@ -800,7 +800,10 @@ CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
|
|||||||
topic_id="campaigns.workflow.view-delivery-report",
|
topic_id="campaigns.workflow.view-delivery-report",
|
||||||
title="Review campaign delivery details",
|
title="Review campaign delivery details",
|
||||||
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.",
|
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.",
|
||||||
body="The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface.",
|
body=("The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface. "
|
||||||
|
"Each job shows all frozen To, Cc and Bcc addresses in their authored order; legacy jobs without a recipient snapshot fall back to the primary recipient. A primary address is a row identity, not proof that only one addressee was sent. The compact list includes recipient data only with recipient-read authority; the separate aggregate report remains address-free. SMTP and IMAP status columns use selectable lists with shared labels. "
|
||||||
|
"Send now, inline retry/continue and inline append-to-Sent share a blocking progress dialog. It polls only a small, campaign-read and object-authorized version-scoped counter endpoint, not the workspace, recipients, attachments or full summary. Processed includes accepted/appended, failed, uncertain and cancelled outcomes; active work is separate from pending so no message disappears between counts. Excluded or non-requested channel work is outside its denominator. A failed progress read retains the last counters and does not imply a failed operation. A disconnected request may still be running: do not repeat it blindly. Acknowledged results survive later refresh failures. "
|
||||||
|
"Append Sent acts on the selected version, never silently on every historical version. Mail reuses a bounded authenticated connection and folder lookup across sequential APPEND commands, with current policy, references, credentials and recovery fences checked per message. Default bounds are 100 APPENDs or 300 seconds per connection; this is connection reuse, not an all-or-nothing mailbox transaction. Unknown APPEND results require evidence-backed reconciliation, never automatic replay."),
|
||||||
order=38,
|
order=38,
|
||||||
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
|
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||||
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
|
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
|
||||||
|
|||||||
@@ -7,11 +7,16 @@ from govoplan_core.core.modules import DocumentationTopic
|
|||||||
|
|
||||||
|
|
||||||
_TRANSLATIONS = {
|
_TRANSLATIONS = {
|
||||||
|
"campaigns.admin.delivery-policy": {
|
||||||
|
"title": "Die Grenze für interaktiven Campaign-Versand konfigurieren",
|
||||||
|
"summary": "Eine auditierte Systemgrenze für „Jetzt senden“ und engere Mandantengrenzen festlegen, ohne Kampagnen zu ändern oder Nachrichten zu senden.",
|
||||||
|
"body": "Administration → SYSTEM → Campaign-Versand erlaubt mit system:settings:read das Lesen und mit system:settings:write das Speichern der Empfängerauftragsgrenze. Der unveränderte Standard bleibt 25; Systemadministrierende dürfen ausdrücklich 0–500 wählen, etwa 200 für einen Lauf mit 183 Aufträgen. Administration → TENANT → Campaign-Versand benötigt admin:policies:read/write und darf die geerbte Systemgrenze nur einschränken. Das Entfernen einer Überschreibung stellt Vererbung wieder her. Eine ausdrücklich gesetzte GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS bleibt einschließlich null eine verbindliche Bereitstellungsgrenze. Ohne diesen Wert verhindert der implizite Standard keine autorisierte Systemüberschreibung. Größere interaktive Läufe dauern länger und können Proxy- oder Anfragezeitlimits erreichen; für große Kampagnen bleiben Hintergrund-Worker mit CELERY_ENABLED und funktionierender Redis-/Celery-Infrastruktur die bevorzugte getrennte Betriebsart. Diese Einstellung begrenzt genau einen gespeicherten geeigneten Lauf für „Jetzt senden“, nicht die Kampagnengröße oder Worker-Verteilung. Speichern ändert ausschließlich die gewählte Einstellung mit Revisionskonfliktschutz, Vorher-/Nachher-Konfigurationshistorie und Audit. Es versendet nichts, ändert keine gespeicherten Prüfungen und umgeht weder Mail-, Prüf-, Freigabe- noch Ausführungsintegritätsprüfungen. Bei Fehlern bleibt der Entwurf erhalten. Neuladen verwendet den zentralen Schutz ungespeicherter Änderungen und liest die frische gespeicherte Richtlinie.",
|
||||||
|
},
|
||||||
"campaigns.workflow.create-campaign": {
|
"campaigns.workflow.create-campaign": {
|
||||||
"title": "Eine Kampagne anlegen",
|
"title": "Eine Kampagne anlegen",
|
||||||
"summary": "Eine gesteuerte Kampagne als bearbeitbaren Entwurf beginnen und Zweck sowie Eigentum vor Zustelldaten festlegen.",
|
"summary": "Eine gesteuerte Kampagne als bearbeitbaren Entwurf beginnen und Zweck sowie Eigentum vor Zustelldaten festlegen.",
|
||||||
"body": (
|
"body": (
|
||||||
"Eine neue Kampagne beginnt mit einer bearbeitbaren Arbeitsversion. Die Aktionsleiste zeigt gespeichert, ungespeichert oder speichernd; Verwerfen steht direkt vor Speichern, und beim Verlassen eines geänderten Entwurfs greift der zentrale Speichern-oder-Verwerfen-Schutz. Destruktive Lebenszyklusaktionen sind von gewöhnlichen Aktionen getrennt. Das Anlegen gewährt keinen Zugriff auf Mail-Profile, verwaltete Dateien, Adressquellen oder Zustellaktionen; diese bleiben eigenständig autorisiert."
|
"Eine neue Kampagne beginnt mit einer bearbeitbaren Arbeitsversion. Die Aktionsleiste zeigt gespeichert, ungespeichert oder speichernd; Verwerfen steht direkt vor Speichern, und beim Verlassen eines geänderten Entwurfs greift der zentrale Speichern-oder-Verwerfen-Schutz. Destruktive Lebenszyklusaktionen sind von gewöhnlichen Aktionen getrennt. Das Anlegen gewährt keinen Zugriff auf Mail-Profile, verwaltete Dateien, Adressquellen oder Zustellaktionen; diese bleiben eigenständig autorisiert. Bestätigtes Speichern und anschließendes Neuladen sind getrennte Ergebnisse. Ein fehlgeschlagener Schreibvorgang oder abgebrochener Konflikt erhält den Entwurf; wiederholtes Speichern teilt sich einen laufenden Vorgang. Eingaben während des Speicherns bleiben als neuere ungespeicherte Arbeit erhalten. Fehlgeschlagenes oder durch neuere Änderungen überholtes Neuladen beim Verwerfen erhält den Entwurf. Unveränderte ZIP-Konfiguration sperrt nicht das Speichern unabhängiger Anlagenquellen oder Regeln. Hinzufügen-Aktionen bleiben kompakt. Eine vorübergehend fehlende Dateiauswahl wird erklärt und verwandelt verwaltete Pfade nicht stillschweigend in Texteingaben; fehlende Regelquellen müssen neu gewählt werden. Eingabe oder Leertaste öffnet die Auswahl am fokussierten Pfadfeld."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.workflow.create-editable-successor": {
|
"campaigns.workflow.create-editable-successor": {
|
||||||
@@ -54,6 +59,7 @@ _TRANSLATIONS = {
|
|||||||
"summary": "Gesteuerte Dateiversionen wählen, Regelzuordnungen prüfen und exakt verwendete Dateien im Build-Nachweis bewahren.",
|
"summary": "Gesteuerte Dateiversionen wählen, Regelzuordnungen prüfen und exakt verwendete Dateien im Build-Nachweis bewahren.",
|
||||||
"body": (
|
"body": (
|
||||||
"Verwaltete Anhänge bleiben Eigentum von Files. Campaign speichert gesteuerte Referenzen und eingefrorene Build-Nachweise; es übernimmt keine Files-Administrationsbefugnis und akzeptiert keine beliebigen Serverpfade."
|
"Verwaltete Anhänge bleiben Eigentum von Files. Campaign speichert gesteuerte Referenzen und eingefrorene Build-Nachweise; es übernimmt keine Files-Administrationsbefugnis und akzeptiert keine beliebigen Serverpfade."
|
||||||
|
" Verknüpfen Sie benötigte Dateien vor dem Sperren. Prüfen und Senden erklärt die Reihenfolge und warum eine gesperrte Version keine Dateiverknüpfungen mehr ändern darf. Während die Anhangsvorschau lädt, ist Sperren nicht verfügbar. Vor der Sperraktion werden Treffer frisch geprüft; neue unverbundene Treffer benötigen die ausdrückliche Bestätigung Verknüpfen und sperren. Bei fehlgeschlagener Prüfung bleibt die Version ungesperrt. Verknüpfen Sie fehlende Dateien in einer bearbeitbaren Version und validieren, bauen und prüfen Sie erneut."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.workflow.queue-delivery": {
|
"campaigns.workflow.queue-delivery": {
|
||||||
@@ -74,7 +80,8 @@ _TRANSLATIONS = {
|
|||||||
"title": "Einen kleinen kontrollierten Lauf sofort senden",
|
"title": "Einen kleinen kontrollierten Lauf sofort senden",
|
||||||
"summary": "Geeignete Aufträge nur nach bewusster Bestätigung synchron ausführen, dass die geprüfte Kampagne klein genug ist.",
|
"summary": "Geeignete Aufträge nur nach bewusster Bestätigung synchron ausführen, dass die geprüfte Kampagne klein genug ist.",
|
||||||
"body": (
|
"body": (
|
||||||
"„Jetzt senden“ ist durch die wirksame maximale Anzahl von Empfängeraufträgen aus Deployment und Mandant geschützt. Der Server zählt den exakt gespeicherten geeigneten Build, lehnt einen zu großen oder leeren Lauf vor SMTP ab und prüft jede Nachricht sowie die Mail-Profilrevision vor der ersten Providerwirkung."
|
"„Jetzt senden“ ist durch die wirksame maximale Anzahl von Empfängeraufträgen aus Bereitstellung, System und Mandant geschützt, nicht durch eine Grenze der Kampagnengröße. Standard ist 25. Administration → SYSTEM → Campaign-Versand erlaubt 0–500 innerhalb einer ausdrücklichen Bereitstellungsgrenze; TENANT darf die geerbte Grenze nur einschränken. Der Server zählt den exakt gespeicherten geeigneten Build, lehnt einen zu großen oder leeren Lauf vor SMTP ab und prüft jede Nachricht sowie die Mail-Profilrevision vor der ersten Providerwirkung. Worker-Versand bleibt unabhängig und benötigt funktionierende Hintergrundinfrastruktur."
|
||||||
|
" Ein erfolgreicher Mail-Servertest belegt nur diese Verbindung, nicht die Ressourcenauswahl oder Absender-/Empfängerberechtigung der Kampagne. Die Vorprüfung unterscheidet Mail-Profil-/Zugangsdatenrichtlinien, SMTP-Konfiguration, Authentifizierung und Verbindung, ohne Geheimnisse oder rohe Providerdetails anzuzeigen. SMTP prüft seine eigene Zugangsdatenwahl; IMAP prüft seine Auswahl getrennt beim Ablegen in Gesendet. Die vollständige Kampagnenvalidierung prüft weiterhin alle erforderlichen Auswahlen."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.workflow.view-aggregate-delivery-report": {
|
"campaigns.workflow.view-aggregate-delivery-report": {
|
||||||
@@ -89,6 +96,9 @@ _TRANSLATIONS = {
|
|||||||
"summary": "Zustellsummen und empfängerbezogene Auftragsnachweise in der aktuellen Campaign-Berichtsoberfläche einsehen.",
|
"summary": "Zustellsummen und empfängerbezogene Auftragsnachweise in der aktuellen Campaign-Berichtsoberfläche einsehen.",
|
||||||
"body": (
|
"body": (
|
||||||
"Der empfängerbezogene Bericht erfordert Lesezugriff auf Kampagne, Bericht und Empfänger. Infrastrukturdiagnosen bleiben getrennt autorisiert; der Server prüft jede direkte Detailroute unabhängig von der Oberfläche."
|
"Der empfängerbezogene Bericht erfordert Lesezugriff auf Kampagne, Bericht und Empfänger. Infrastrukturdiagnosen bleiben getrennt autorisiert; der Server prüft jede direkte Detailroute unabhängig von der Oberfläche."
|
||||||
|
" Jeder Auftrag zeigt alle eingefrorenen An-, Cc- und Bcc-Adressen in gespeicherter Reihenfolge; ältere Aufträge ohne Empfängersnapshot verwenden die primäre Adresse. Diese identifiziert die Zeile und beweist nicht, dass nur eine Adresse angeschrieben wurde. Die kompakte Liste erfordert weiterhin Empfänger-Leseberechtigung; der getrennte aggregierte Bericht bleibt ohne Adressen. SMTP- und IMAP-Zustände verwenden Auswahllisten mit gemeinsamen Bezeichnungen."
|
||||||
|
" Jetzt senden, synchrone Wiederholung/Fortsetzung und Kopieren nach Gesendet verwenden denselben sperrenden Fortschrittsdialog. Er liest ausschließlich kleine versionsbezogene Zähler mit Kampagnen-Lese- und Objektberechtigung, nicht Arbeitsbereich, Empfänger, Anhänge oder vollständige Zusammenfassung. Verarbeitet umfasst angenommene/kopierte, fehlgeschlagene, ungewisse und abgebrochene Ergebnisse. Laufende Aufträge werden getrennt von ausstehenden gezählt; ausgeschlossene oder nicht angeforderte Kanäle gehören nicht zum Nenner. Bei Lesefehlern bleiben die letzten Zähler erhalten; dies bedeutet keinen fehlgeschlagenen Versand. Nach einer getrennten Anfrage kann die Verarbeitung weiterlaufen: Wiederholen Sie sie nicht blind. Bestätigte Ergebnisse bleiben bei späteren Aktualisierungsfehlern erhalten."
|
||||||
|
" Kopieren nach Gesendet betrifft nur die ausgewählte Version, nicht stillschweigend historische Versionen. Mail verwendet eine begrenzte authentifizierte Verbindung und Ordnerauflösung für nacheinander ausgeführte APPEND-Befehle; Richtlinie, Referenzen, Zugangsdaten und Wiederherstellungsschutz werden je Nachricht neu geprüft. Standardgrenzen sind 100 APPENDs oder 300 Sekunden pro Verbindung. Dies ist Verbindungswiederverwendung, keine atomare Postfachtransaktion. Ungewisse APPEND-Ergebnisse benötigen nachweisgestützten Abgleich und werden niemals automatisch wiederholt."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.workflow.export-delivery-report": {
|
"campaigns.workflow.export-delivery-report": {
|
||||||
@@ -158,20 +168,25 @@ _TRANSLATIONS = {
|
|||||||
"title": "Ein Mail-Profil für die Kampagnenzustellung wählen",
|
"title": "Ein Mail-Profil für die Kampagnenzustellung wählen",
|
||||||
"summary": "Campaign referenziert ein autorisiertes Mail-Profil und speichert niemals SMTP-/IMAP-Einstellungen oder Zugangsdaten.",
|
"summary": "Campaign referenziert ein autorisiertes Mail-Profil und speichert niemals SMTP-/IMAP-Einstellungen oder Zugangsdaten.",
|
||||||
"body": (
|
"body": (
|
||||||
"In den Mail-Einstellungen der Kampagne wird ein verfügbares Profil ausgewählt, über Mail getestet und gespeichert. Validierung und Zustellung prüfen die Profilberechtigung erneut. Eine geänderte Transportidentität erfordert neue Validierung und neuen Build."
|
"Mail-Einstellungen → Wiederverwendbares Mail-Profil → SMTP-Zugangsdaten (bei Nutzung auch IMAP-Zugangsdaten) speichert eine ausdrückliche Zugangsdatenkennung. Eine leere Auswahl bedeutet Vererbung nur soweit die Mail-Richtlinie dies erlaubt; ein Profilstandard ist keine gespeicherte Kampagnenauswahl. Fehlende oder inaktive gespeicherte Profile, Server und Zugangsdaten bleiben sichtbar nicht verfügbar und werden nicht stillschweigend ersetzt. "
|
||||||
|
"In den Mail-Einstellungen der Kampagne wird ein verfügbares Profil ausgewählt, über Mail getestet und gespeichert. Bei gemeldeten kampagnenlokalen Alt-Transportdaten wählen Sie nach der autorisierten Profilauswahl „Ausgewähltes Mail-Profil migrieren“. Diese ausdrückliche Aktion funktioniert auch bei unveränderter Profilauswahl und unberührtem Entwurf. Gesperrte historische Nachweise bleiben erhalten; verwenden Sie zuvor die angebotene Entsperrung oder bearbeitbare Nachfolgeversion. „Prüfen und Senden“ zeigt den Migrationsblocker mit Rückweg zu den Mail-Einstellungen, statt wiederholt eine ungültige Anhangsvorschau anzufordern. Die Profilauswahl lädt nur für die Kampagne autorisierte Profile; ein Fehler beim getrennten administrativen Richtlinienkatalog leert sie nicht. Validierung und Zustellung prüfen die Profilberechtigung erneut. Migration speichert Konfiguration, versendet aber keine E-Mail. Validieren, bauen und prüfen Sie die resultierende Version vor der Zustellung erneut."
|
||||||
|
" Mail-Migration und ZIP-Richtlinienkorrekturen lassen sich in beliebiger Reihenfolge speichern. Unveränderte ZIP-Einstellungen blockieren die Migration nicht und erhalten keinen neuen Zustimmungsnachweis. Eine Archiv- oder Inhaltskorrektur mit unveränderten Mail-Referenzen erhält den alten Transport serverseitig und zeigt weiterhin den Migrationshinweis; es erfolgt keine stillschweigende Migration. Ein bestätigtes Speichern und das anschließende Neuladen des Arbeitsbereichs sind getrennte Ergebnisse: Bei fehlgeschlagenem Neuladen bleiben die letzten nutzbaren Daten derselben Kampagne und Version sichtbar, ergänzt um den Fehler. Wiederholen Sie Neuladen; veraltete Antworten einer anderen Kampagne, Version, Identität oder früheren Aktualisierung dürfen den aktuellen Arbeitsbereich nicht ersetzen."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.mail-profile-governance": {
|
"campaigns.mail-profile-governance": {
|
||||||
"title": "Campaign-zu-Mail-Profilreferenzen steuern",
|
"title": "Campaign-zu-Mail-Profilreferenzen steuern",
|
||||||
"summary": "Mail besitzt Transportdefinitionen und verschlüsselte Zugangsdaten; Campaign nur die Profilreferenz und Zustellnachweise.",
|
"summary": "Mail besitzt Transportdefinitionen und verschlüsselte Zugangsdaten; Campaign nur die Profilreferenz und Zustellnachweise.",
|
||||||
"body": (
|
"body": (
|
||||||
"Kampagnenverfassende erhalten mail:profile:use; verfügbare Profile werden über Mail-Policy begrenzt und wirksame Zugangsdatenvererbung bleibt aktiv. Inline-Transportfelder werden abgelehnt. Altbestände bleiben unverändert, bis eine ausdrückliche auditierte Profilmigration eine bearbeitbare Version erzeugt oder aktualisiert."
|
"Kampagnenverfassende erhalten mail:profile:use; verfügbare Profile werden über Mail-Policy begrenzt und es wird ausdrücklich festgelegt, ob Profil-Zugangsdaten geerbt werden dürfen oder eine Kampagne Mail-eigene Zugangsdaten auswählen muss. Die Mail-Richtlinienseite zeigt die SMTP-/IMAP-Zugangsdatenvererbung mit lokalen, geerbten und wirksamen Werten sowie übergeordneten Sperren. Inline-Transportfelder werden abgelehnt und niemals samt Zugangsdaten an den Browser zurückgegeben. Altbestände bleiben unverändert, bis eine ausdrückliche auditierte Profilmigration eine bearbeitbare Version erzeugt oder aktualisiert. Dafür genügt auch das schon referenzierte Profil, wenn „Ausgewähltes Mail-Profil migrieren“ verwendet wird. Mail-Einstellungen laden nur die für diese Kampagne nutzbaren Profile; der administrative Richtlinienkatalog wird getrennt auf der Mail-Richtlinienseite angefordert und Fehler bleiben dort sichtbar. Diese Trennung verleiht keine Profiladministration und umgeht weder Eigentümer-, Mandanten- noch Mail-Autorisierung. Migration versendet keine E-Mail und stellt keinen alten Ausführungssnapshot wieder her."
|
||||||
|
" Unabhängige Entwurfskorrekturen dürfen das exakt gespeicherte alte Serverobjekt nur bei unveränderten öffentlichen Mail-Referenzen erhalten; Inline-Transport darf nicht mitgesendet werden. Dabei wird Inhalt gespeichert, kein Profil ausgewählt oder genutzt, auch nach Entzug seiner Berechtigung. Ausdrückliche Migration benötigt weiterhin mail:profile:use und aktuelle Mail-Policy. Der Versions-Auditnachweis unterscheidet legacy_mail_settings_preserved und legacy_mail_settings_migrated. Unveränderte ZIP-Konfiguration wird nicht erneut bestätigt; geänderte ZIP-Einstellungen unterliegen allen Richtlinienprüfungen. Erfolgreiche Korrekturen entwerten bisherige Build- und Ausführungsnachweise und lockern weder Validierung noch Prüfung oder Versand."
|
||||||
|
" Bereits migrierte Entwürfe folgen derselben Regel für unveränderte Referenzen: Eine spätere Pflicht zur ausdrücklichen SMTP-/IMAP-Zugangsdatenwahl verhindert keine unabhängige Archiv- oder Inhaltskorrektur. Jede geänderte Mail-Ressourcenauswahl benötigt weiterhin Mail-Berechtigung und aktuelle Richtlinie; verbindliche Validierung und Zustellung prüfen stets die vollständige Auswahl erneut."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.mail-profile-operations": {
|
"campaigns.mail-profile-operations": {
|
||||||
"title": "Profilbasierte Kampagnenzustellung betreiben",
|
"title": "Profilbasierte Kampagnenzustellung betreiben",
|
||||||
"summary": "Worker autorisieren und lösen Mail-Profile bei Ausführung neu auf; Campaign bewahrt nur undurchsichtige Mail-Revisionen und Ergebnisse.",
|
"summary": "Worker autorisieren und lösen Mail-Profile bei Ausführung neu auf; Campaign bewahrt nur undurchsichtige Mail-Revisionen und Ergebnisse.",
|
||||||
"body": (
|
"body": (
|
||||||
|
"SMTP- und IMAP-Laufzeitaktionen prüfen die Zugangsdatenpflicht getrennt je Protokoll; ein SMTP-Aufruf benötigt keine IMAP-Parameter und umgekehrt. Vollständige Kampagnenvalidierung und Build-Zusammenfassung prüfen weiterhin beide erforderlichen Auswahlen. Die Vorprüfung unterscheidet Mail-Profil-/Zugangsdatenrichtlinienfehler von SMTP-Konfigurations-, Authentifizierungs- und Verbindungsfehlern. Ein erfolgreicher Servertest ersetzt keine kampagnenspezifische Autorisierung. "
|
||||||
"Ein Altsnapshot, unautorisiertes oder inaktives Profil, Referenzkonflikt oder eine geänderte SMTP-/IMAP-Revision stoppt die Zustellung. Synchrone Stapel prüfen DNS, Verbindung, TLS und Authentifizierung vor der ersten Wirkung, verwenden eine begrenzte gesunde SMTP-Verbindung wieder und verbinden bei Alterung neu. Oberfläche und Bericht zeigen Stapel-, Verbindungs-, Wiederverbindungs-, Fehler- und Pausenzahlen. Systemische Authentifizierungs-, Absender- oder Verbindungsfehler pausieren übrige Aufträge mit stabilem Grund; das Profil ist zu korrigieren und zu testen, bevor ausdrücklich fortgesetzt wird. Verbindungsverlust nach Beginn von DATA bleibt ergebnisoffen und wird nicht automatisch wiederholt. Der Datensatz wird bewahrt, Profilwahl korrigiert, erneut validiert und gebaut und erst dann neu eingereiht. Reine Passwortrotation kopiert keine Geheimnisse nach Campaign. Unsichere SMTP-/IMAP-Wirkungen bleiben bis zum evidenzbasierten Betriebsabgleich blockiert. Wird Campaign nach Annahme eines Auftrags für den Mandanten unzugänglich, bleibt er unangetastet und wird als Betriebsaktion gemeldet."
|
"Ein Altsnapshot, unautorisiertes oder inaktives Profil, Referenzkonflikt oder eine geänderte SMTP-/IMAP-Revision stoppt die Zustellung. Synchrone Stapel prüfen DNS, Verbindung, TLS und Authentifizierung vor der ersten Wirkung, verwenden eine begrenzte gesunde SMTP-Verbindung wieder und verbinden bei Alterung neu. Oberfläche und Bericht zeigen Stapel-, Verbindungs-, Wiederverbindungs-, Fehler- und Pausenzahlen. Systemische Authentifizierungs-, Absender- oder Verbindungsfehler pausieren übrige Aufträge mit stabilem Grund; das Profil ist zu korrigieren und zu testen, bevor ausdrücklich fortgesetzt wird. Verbindungsverlust nach Beginn von DATA bleibt ergebnisoffen und wird nicht automatisch wiederholt. Der Datensatz wird bewahrt, Profilwahl korrigiert, erneut validiert und gebaut und erst dann neu eingereiht. Reine Passwortrotation kopiert keine Geheimnisse nach Campaign. Unsichere SMTP-/IMAP-Wirkungen bleiben bis zum evidenzbasierten Betriebsabgleich blockiert. Wird Campaign nach Annahme eines Auftrags für den Mandanten unzugänglich, bleibt er unangetastet und wird als Betriebsaktion gemeldet."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -180,13 +195,19 @@ _TRANSLATIONS = {
|
|||||||
"summary": "Gesteuerte Empfänger-, Vorlagen-, Anhangs- und Mail-Profil-Eingaben in exakte Nachrichten zur Prüfung überführen.",
|
"summary": "Gesteuerte Empfänger-, Vorlagen-, Anhangs- und Mail-Profil-Eingaben in exakte Nachrichten zur Prüfung überführen.",
|
||||||
"body": (
|
"body": (
|
||||||
"Jede Eingabe wird in ihrer verantwortlichen Oberfläche vorbereitet, alle blockierenden Validierungsprobleme werden gelöst und exakte Empfängernachrichten vor der Prüfung gebaut. Empfängerzeilen können als eine ausdrücklich bestätigte Entwurfsänderung gesammelt aktiviert oder deaktiviert werden; Speichern erzeugt normale Versionsnachweise und verwirft veraltete Validierungs-, Build- und Prüfzustände. Passwortfelder verwenden den zentralen sicheren Generator, dessen Vorschlag erst nach „Passwort verwenden“ übernommen wird. Campaign friert Empfänger- und Anhangsnachweise für die ausgewählte Version ein; spätere Quelländerungen ändern den Build nicht. Kennzahlen bieten nur dann einen benannten Drill-down, wenn eine autorisierte Quellsammlung, gefilterte Prüftabelle, Anhangsvorschau oder ein Bericht eine Handlung ermöglicht. Datenschutzunterdrückte Aggregate bleiben nicht interaktiv. Ist Templates installiert, besitzt dessen einziger Navigationseintrag die wiederverwendbare Bibliothek; kampagnenspezifische Komposition bleibt im Arbeitsbereich."
|
"Jede Eingabe wird in ihrer verantwortlichen Oberfläche vorbereitet, alle blockierenden Validierungsprobleme werden gelöst und exakte Empfängernachrichten vor der Prüfung gebaut. Empfängerzeilen können als eine ausdrücklich bestätigte Entwurfsänderung gesammelt aktiviert oder deaktiviert werden; Speichern erzeugt normale Versionsnachweise und verwirft veraltete Validierungs-, Build- und Prüfzustände. Passwortfelder verwenden den zentralen sicheren Generator, dessen Vorschlag erst nach „Passwort verwenden“ übernommen wird. Campaign friert Empfänger- und Anhangsnachweise für die ausgewählte Version ein; spätere Quelländerungen ändern den Build nicht. Kennzahlen bieten nur dann einen benannten Drill-down, wenn eine autorisierte Quellsammlung, gefilterte Prüftabelle, Anhangsvorschau oder ein Bericht eine Handlung ermöglicht. Datenschutzunterdrückte Aggregate bleiben nicht interaktiv. Ist Templates installiert, besitzt dessen einziger Navigationseintrag die wiederverwendbare Bibliothek; kampagnenspezifische Komposition bleibt im Arbeitsbereich."
|
||||||
|
" In individuellen und globalen Adressdialogen bestimmen die Auf-/Ab-Aktionen die gespeicherte Adressreihenfolge. Speichern im Dialog übernimmt diese Reihenfolge ohne alphabetische Neusortierung in den Kampagnenentwurf; doppelte E-Mail-Adressen behalten ihre erste Position. Eingefügte Adressen werden in Eingabereihenfolge angehängt, ohne vorhandene Adressen umzuordnen. Die erste individuelle An-Adresse bleibt der primäre Name und die E-Mail-Adresse der Empfängerzeile. Speichern Sie anschließend die Kampagnenseite, um den Entwurf dauerhaft zu übernehmen; bei einem Fehler bleibt die Reihenfolge für einen ausdrücklichen neuen Speicherversuch erhalten. Abbrechen verwirft gezielt nur die noch unbestätigten Dialogänderungen."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.workflow.complete-review": {
|
"campaigns.workflow.complete-review": {
|
||||||
"title": "Die Kampagnenprüfung abschließen",
|
"title": "Die Kampagnenprüfung abschließen",
|
||||||
"summary": "Kritische Blocker lösen, einzelne Nachrichten entscheiden und unkritische Punkte für genau einen Build bestätigen.",
|
"summary": "Kritische Blocker lösen, einzelne Nachrichten entscheiden und unkritische Punkte für genau einen Build bestätigen.",
|
||||||
"body": (
|
"body": (
|
||||||
"Der Prüfabschluss bleibt an aktuellen Build-Token, geprüfte Nachrichtenschlüssel, dokumentierte Problementscheidungen und Nachrichtennachweise gebunden. Ausdrückliche Aktionen auf handlungsfähigen Empfänger-, Anhangs-, Validierungs- und Prüfkennzahlen öffnen Quellseite, Nachweisvorschau oder gefilterte Nachrichtentabelle. Reine Information und datenschutzunterdrückte Werte werden nicht zu versteckten Klickzielen. Änderungen an Empfängern, Inhalt, Anhängen, Eigentümerkontext oder nicht geheimer Transportidentität erfordern erneut Validierung, Build und Prüfung."
|
"Das Öffnen der Vorlage ohne Bearbeitung, Änderungen des Schreibschutzes und der Wechsel zwischen visueller Ansicht und Quelltext erhalten das gespeicherte HTML unverändert und erfordern beim Verlassen kein Speichern. Der Prüfabschluss bleibt an aktuellen Build-Token, geprüfte Nachrichtenschlüssel, dokumentierte Problementscheidungen und Nachrichtennachweise gebunden. Normales Speichern sendet nur die clientverantworteten Metadaten created_from, field_overrides und opt_ins; review_send und approval_gate sind lesbare Servernachweise, aber keine schreibbaren Editorfelder. Werden sie bei einem Metadaten-Speichern ausgelassen, bleiben sie serverseitig erhalten. Die vorgesehenen Regeln für Entsperrung, Nachfolgeversionen und Build-Invalidierung entfernen veraltete Nachweise weiterhin. Bei notwendiger Mail-Altdatenmigration bleibt „Prüfen und Senden“ schreibgeschützt, unterdrückt inkompatible Anhangsvorschau-Anfragen und bietet „Mail-Einstellungen öffnen“ für genau die ausgewählte Version. Migrieren, validieren, bauen und prüfen Sie vor dem Senden erneut. Ausdrückliche Aktionen auf handlungsfähigen Empfänger-, Anhangs-, Validierungs- und Prüfkennzahlen öffnen Quellseite, Nachweisvorschau oder gefilterte Nachrichtentabelle. Reine Information und datenschutzunterdrückte Werte werden nicht zu versteckten Klickzielen. Änderungen an Empfängern, Inhalt, Anhängen, Eigentümerkontext oder nicht geheimer Transportidentität erfordern erneut Validierung, Build und Prüfung."
|
||||||
|
" Gleichartige Prüfbedingungen bestätigen gruppiert ausschließlich vom Server zugelassene, ungeprüfte Nachrichten aus der aktuell geladenen passenden Auswahl. Wählen Sie eine verständlich benannte Kategorie, prüfen Sie die gezählte Empfängerauswahl und geben Sie bei Anhangsausnahmen eine gemeinsame Begründung an. Jede Anfrage benennt höchstens 200 konkrete Nachrichten und prüft aktuellen Build und Kategorie; wiederholen Sie dies für verbleibende Nachrichten, statt andere Kategorien oder nicht geladene Nachrichten als mitbestätigt anzusehen. Jede ausgewählte Nachricht erhält einen eigenen eingefrorenen, zuordenbaren Entscheidungsnachweis. Bei fehlgeschlagenem Speichern bleiben Begründung und Auswahl für einen ausdrücklichen Wiederholungsversuch erhalten; ein geänderter Build verhindert veraltete Bestätigungen. Die Gruppenbestätigung sendet keine Nachrichten und schließt die abschließende Prüfung nicht ab. Harte Blocker können nicht übergangen werden. Beabsichtigte richtlinienbedingte Ausschlüsse und ausdrücklich erlaubte Anhangsregeln ohne Treffer bleiben informativ und benötigen keine Prüfentscheidung."
|
||||||
|
" Eine einzelne Annahme speichert Begründung und Prüfstatus sofort, schon vor dem vollständigen Prüfabschluss; Neuladen setzt den bestätigten Fortschritt desselben Builds fort. Bei fehlgeschlagenem Speichern oder einem Konflikt bleibt die Begründung für einen ausdrücklichen neuen Versuch erhalten; die Nachricht gilt noch nicht als geprüft. Jeder Speichervorgang ergänzt nur ausgewählte Nachrichten und erhält fremde Prüfnachweise, ohne Nachrichten neu zu bauen, Anhangsdateien zu prüfen oder den gesamten Arbeitsbereich neu zu laden. Teilfortschritt erlaubt keinen Versand; der abschließende Prüfabschluss kontrolliert weiterhin alle erforderlichen Entscheidungen und harten Blocker. Pflichtanhänge und harte Sperrrichtlinien bleiben gegenüber optional erlaubten leeren Treffern vorrangig; auch die getrennte Kampagnenrichtlinie für vollständig anhangslose Nachrichten gilt weiterhin."
|
||||||
|
" Speichern benötigt die Campaign-Prüfberechtigung, die aktuelle Versionsrevision und den sicheren operativen Bezug review_build_token; Diagnoseberechtigung ist nicht erforderlich. Veraltete Builds oder gleichzeitige Änderungen führen zu einem Konflikt ohne Überschreiben gespeicherten Fortschritts."
|
||||||
|
" Bestätigte oder erwartete Anhangsbedingungen bleiben für denselben Build auch bei „Bestätigen und senden“ erfüllt; fehlende oder mehrdeutige Quelltreffer bleiben als Kontext sichtbar, erzeugen aber keine zweite Bestätigungspflicht. Der Mock-Test nach der Prüfung verwendet verifizierte eingefrorene Nachrichten und abgeschlossene Entscheidungen statt einer Neuerstellung. Geänderte Eingaben, Problemnachweise, Nachrichtenbytes oder Mail-Transport stoppen den Test vor der Mock-Aufzeichnung; Entwurfsvorschauen behalten ihren getrennten vorläufigen Build."
|
||||||
|
" Validierungsdetails und Listen mehrfach verwendeter Dateien zeigen alle Einträge über die zentrale DataGrid-Seitensteuerung. Zusammengehörige fehlende Regeltreffer und die Richtlinienfolge einer anhangslosen Nachricht werden gemeinsam erklärt; aufklappbare technische Nachweise bleiben erhalten. Nachrichtentabelle und Filter verwenden vier operative Zustände: Bereit, Prüfung erforderlich, Blockiert und Ausgeschlossen. Angenommene ausdrückliche Entscheidungen werden Bereit; noch unbestätigte Warnungen bleiben Prüfung erforderlich. Eine zweite Spalte erklärt den Zustand. Diese Darstellung löscht oder verändert keine eingefrorenen Probleme oder Auditnachweise."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.workflow.retry-and-reconcile": {
|
"campaigns.workflow.retry-and-reconcile": {
|
||||||
@@ -194,13 +215,18 @@ _TRANSLATIONS = {
|
|||||||
"summary": "Sicher wiederholbare Fehler von Mail-, Postbox- oder IMAP-Wirkungen mit unbekanntem Ergebnis trennen.",
|
"summary": "Sicher wiederholbare Fehler von Mail-, Postbox- oder IMAP-Wirkungen mit unbekanntem Ergebnis trennen.",
|
||||||
"body": (
|
"body": (
|
||||||
"Eine Wiederholung erzeugt neuen Versuchsnachweis und ist nur für ausdrücklich geeignete Zustände zulässig. Unbekannte Mail-, Postbox- oder IMAP-Wirkungen dürfen nie blind wiederholt werden. Externe Nachweise sind zu prüfen und der betroffene Kanal vor dem Fortsetzen abzugleichen. Angenommene Mail-Versuche und Postbox-Ziele bleiben bei Teilwiederholungen unveränderlich; die Reparatur von „Gesendet“ versendet angenommene Mail nicht erneut."
|
"Eine Wiederholung erzeugt neuen Versuchsnachweis und ist nur für ausdrücklich geeignete Zustände zulässig. Unbekannte Mail-, Postbox- oder IMAP-Wirkungen dürfen nie blind wiederholt werden. Externe Nachweise sind zu prüfen und der betroffene Kanal vor dem Fortsetzen abzugleichen. Angenommene Mail-Versuche und Postbox-Ziele bleiben bei Teilwiederholungen unveränderlich; die Reparatur von „Gesendet“ versendet angenommene Mail nicht erneut."
|
||||||
|
" Ohne Worker bietet der Bericht ausdrücklich bestätigte, begrenzte Wiederholung und Fortsetzung über dieselben unveränderlichen Aufträge, Ausführungsprüfungen, Prüfnachweise, Freigaben, Mail-Berechtigungen, Ratenbegrenzungen und Wiederherstellungsnachweise wie Jetzt senden. Jede Anfrage bleibt innerhalb der wirksamen synchronen Grenze und meldet verbleibende Arbeit; bereits angenommene, ausgeschlossene, aktive und ungewisse Aufträge werden nicht erneut gesendet. Wiederholung benötigt campaigns:campaign:retry und synchron zusätzlich campaigns:campaign:send; Fortsetzen benötigt campaigns:campaign:queue und campaigns:campaign:send. Abgleich benötigt campaigns:campaign:reconcile und eine sachliche Nachweisnotiz; er sendet nichts."
|
||||||
|
" Ein festhängender übernommener, sendender oder kopierender Auftrag ist nicht allein durch Zeitablauf sicher. Der Bericht bietet die Wiederherstellung eines unterbrochenen Auftrags nur bei abgelaufener dauerhafter Sperre und nachweislich gestoppter oder ersetzter ursprünglicher Laufzeit. Die mitgesendete sichere Revision muss noch passen, und ursprüngliche Wiederherstellungsnachweise müssen gültig sein. Die Aktion setzt das Ergebnis ausschließlich auf ungewiss. Prüfen Sie Provider- beziehungsweise Postfachnachweise und gleichen Sie angenommen/nicht gesendet oder kopiert/nicht kopiert getrennt ab, bevor Sie ausdrücklich wiederholen. Fehlende Sperr- oder Versuchsnachweise und unbestätigte Besitzer bleiben zur betrieblichen Untersuchung gesperrt. Ein doppelter Worker-Aufruf verändert aktive Zustände nicht."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.reference.composition-assurance": {
|
"campaigns.reference.composition-assurance": {
|
||||||
"title": "Die Campaign-Referenzkomposition absichern",
|
"title": "Die Campaign-Referenzkomposition absichern",
|
||||||
"summary": "Campaign nur mit abgestimmten Verträgen, rollensicheren Oberflächen, dauerhaften Wirkungsnachweisen, optionaler Modultrennung und wiederherstellbaren Daten freigeben.",
|
"summary": "Campaign nur mit abgestimmten Verträgen, rollensicheren Oberflächen, dauerhaften Wirkungsnachweisen, optionaler Modultrennung und wiederherstellbaren Daten freigeben.",
|
||||||
"body": (
|
"body": (
|
||||||
|
"Freigabeprüfungen müssen Campaign-Validierung und Anhangsauflösung unabhängig in frischen Prozessen initialisieren, ohne einen früheren Seiten- oder Testimport vorauszusetzen. Diese lokalen Einstiegspunkte bleiben ohne installiertes Mail oder Files nutzbar; ihr Import startet keinen Versand und lockert keine Pfadberechtigungen für verwaltete Dateien. "
|
||||||
"Campaign ist nur dann Referenzkomposition, wenn Core, Mail, Files, Addresses, Worker, Speicher, Policies und Dokumentation in genau der installierten Kombination geprüft sind. Gewöhnliche Lesende sehen Fachzustand statt Pfaden, Speicherschlüsseln, Worker-Claims oder rohen Providerdiagnosen; Diagnose- und Exportbefugnis bleiben getrennt."
|
"Campaign ist nur dann Referenzkomposition, wenn Core, Mail, Files, Addresses, Worker, Speicher, Policies und Dokumentation in genau der installierten Kombination geprüft sind. Gewöhnliche Lesende sehen Fachzustand statt Pfaden, Speicherschlüsseln, Worker-Claims oder rohen Providerdiagnosen; Diagnose- und Exportbefugnis bleiben getrennt."
|
||||||
|
" Prüfen Sie, dass einzelne Begründungen und Prüfzustände schon vor dem vollständigen Abschluss Neuladen überstehen. Teilfortschritt benötigt campaigns:campaign:review und Schreibzugriff, ergänzt genau ausgewählte Nachrichten des aktuellen Builds mit Revisionsprüfung und protokolliert Annahmen ohne fremde Prüfnachweise zu ersetzen. Der operative review_build_token legt keine rohen Diagnosetoken offen. Teilfortschritt erlaubt keinen Versand; harte Blocker sind nicht bestätigbar. Richtlinienbedingte Ausschlüsse und ausdrücklich erlaubte leere optionale Anhangsregeln erzeugen keine neue Prüfpflicht; Pflichtanhänge und globale harte Sperren bleiben wirksam. Diese Auflösungsänderungen gelten nur für neue Builds: Eine bewusste Neuerstellung klassifiziert vorhandene Nachrichten neu und entwertet frühere Prüf- und Freigabenachweise. Eingefrorene historische Auftragsprobleme dürfen nicht aus veränderlichen Richtlinien umgeschrieben werden."
|
||||||
|
" Der ausdrückliche Mock-Modus use_reviewed_build benötigt einen vorhandenen versiegelten Ausführungsnachweis und bei prüfpflichtigen Nachrichten den Abschluss desselben Builds. Vor jeder Mock-Aufzeichnung oder angeforderten Leerung des Mock-Postfachs prüft er Auftrags- und Problemnachweise, EML-Länge, Digest, Message-ID und aktuellen Mail-Transport. Er sendet kein SMTP, verändert keinen Campaign-Zustellstatus und erzeugt keinen fehlenden Altdaten-Snapshot. include_needs_review ist in diesem Modus keine pauschale Umgehung."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.reference.shared-build-artifacts": {
|
"campaigns.reference.shared-build-artifacts": {
|
||||||
@@ -215,6 +241,8 @@ _TRANSLATIONS = {
|
|||||||
"summary": "Standardmäßig AES einsetzen und schwaches Windows-kompatibles ZipCrypto nur mit Policy, Berechtigung, Bestätigung und Nachweis wählen.",
|
"summary": "Standardmäßig AES einsetzen und schwaches Windows-kompatibles ZipCrypto nur mit Policy, Berechtigung, Bestätigung und Nachweis wählen.",
|
||||||
"body": (
|
"body": (
|
||||||
"Campaign löst Archivverschlüsselung über Policy auf System-, Mandanten-, Eigentümer- und Kampagnenebene auf. Passwortgeschützte Archive verwenden AES, außer die vollständig vererbte Richtlinie erlaubt Legacy ZipCrypto ausdrücklich und die handelnde Person besitzt campaigns:archive:use_legacy_zipcrypto. Die Legacy-Auswahl benötigt eine begründete Bestätigung. Passwörter erscheinen weder im Campaign-Nachweis noch in der Nachricht und müssen über den getrennt ausgewählten, per Policy erlaubten Kanal übermittelt werden. Jeder Build friert Archiv- und Mitglied-Hashes, Implementierungsversion, Policy-Hash und -Quellpfad, bestätigende Person, Begründung, Zeitpunkt und Build-Identität ein. Eine später strengere Policy blockiert Einreihen und Senden bis zum Neubau; nach Fehlern wird nie von AES auf ZipCrypto zurückgefallen. Temporärer Klartext und Archive bleiben im begrenzten Build-Verzeichnis und werden nach Erfolg oder Fehler entfernt."
|
"Campaign löst Archivverschlüsselung über Policy auf System-, Mandanten-, Eigentümer- und Kampagnenebene auf. Passwortgeschützte Archive verwenden AES, außer die vollständig vererbte Richtlinie erlaubt Legacy ZipCrypto ausdrücklich und die handelnde Person besitzt campaigns:archive:use_legacy_zipcrypto. Die Legacy-Auswahl benötigt eine begründete Bestätigung. Passwörter erscheinen weder im Campaign-Nachweis noch in der Nachricht und müssen über den getrennt ausgewählten, per Policy erlaubten Kanal übermittelt werden. Jeder Build friert Archiv- und Mitglied-Hashes, Implementierungsversion, Policy-Hash und -Quellpfad, bestätigende Person, Begründung, Zeitpunkt und Build-Identität ein. Eine später strengere Policy blockiert Einreihen und Senden bis zum Neubau; nach Fehlern wird nie von AES auf ZipCrypto zurückgefallen. Temporärer Klartext und Archive bleiben im begrenzten Build-Verzeichnis und werden nach Erfolg oder Fehler entfernt."
|
||||||
|
" Kampagneneinstellungen, Richtlinien und Anhänge zeigen die wirksame Richtlinie und führen berechtigte Administrierende direkt zu Administration → SYSTEM → Campaign archive encryption. Aktivieren Sie dort Legacy ZipCrypto und speichern Sie. Frische Systemstandardwerte sind bearbeitbar, ohne dass das bloße Öffnen bereits eine Ausnahme erzeugt. Mandanten- und Eigentümerrichtlinien können das Ergebnis weiter einschränken. Laden Sie anschließend in Campaign die Archivrichtlinie neu, wählen Sie Legacy ZipCrypto unter Anhänge → ZIP-Anhänge, bestätigen Sie die schwache Verschlüsselung und geben Sie eine betriebliche Begründung mit mindestens 10 Zeichen an. Ohne Policy bleibt Legacy gesperrt. Weder Richtlinien- noch Anhangskonfiguration versendet beim Speichern eine E-Mail."
|
||||||
|
" Mail-Migration und Archivkorrekturen lassen sich unabhängig in beliebiger Reihenfolge speichern. Eine exakt unveränderte ZIP-Konfiguration bleibt bei einer anderen Korrektur ohne erneute Bestätigung erhalten, auch nach Entzug von Richtlinie oder Berechtigung; ihre Nutzung wird dadurch nicht erlaubt. Jede geänderte ZIP-Konfiguration muss aktuelle Methoden, Passwortkanäle und Legacy-Berechtigungs- sowie Bestätigungsvorgaben erfüllen. Eine Archivkorrektur mit unveränderten öffentlichen Mail-Referenzen erhält den alten Transport exakt serverseitig bis zur ausdrücklichen autorisierten Migration. Clients dürfen dabei weder Inline-Transport einführen oder zurücksenden noch Mail-Referenzen ändern oder Bestätigungsnachweise erfinden. Beide Korrekturen entwerten Ausführungs- und Build-Nachweise; Validierung, Prüfung, Erstellung und Versand bleiben bis zur Erfüllung aller Bedingungen gesperrt."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"campaigns.workflow.link-exact-campaign-to-case": {
|
"campaigns.workflow.link-exact-campaign-to-case": {
|
||||||
|
|||||||
@@ -5,7 +5,36 @@ from __future__ import annotations
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'campaigns.admin.collaboration-governance': {'verification': 'Testen Sie einen schreibgeschützten '
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {
|
||||||
|
'campaigns.admin.delivery-policy': {
|
||||||
|
'prerequisites': ['Sie besitzen Lese- und Schreibrechte für die gewünschte System- oder aktive Mandantenebene.'],
|
||||||
|
'steps': ['Öffnen Sie Administration und wählen Sie Campaign-Versand unter SYSTEM oder TENANT.',
|
||||||
|
'Prüfen Sie gespeicherte, geerbte und maximal zulässige Werte; deaktivieren Sie Vererbung für eine ganzzahlige Überschreibung.',
|
||||||
|
'Speichern Sie und prüfen Sie die wirksame Grenze, oder erhalten Sie den Entwurf und lösen Sie einen ausdrücklichen Konflikt vor einem neuen Versuch.',
|
||||||
|
'Kehren Sie zu Prüfen und Senden zurück und laden Sie die Versandoptionen neu, bevor Sie getrennt eine Zustellaktion wählen.'],
|
||||||
|
'limitations': ['Mandanten dürfen die Systemrichtlinie nicht erhöhen; ausdrückliche Bereitstellungsgrenzen lassen sich hier nicht erhöhen.',
|
||||||
|
'Null deaktiviert Jetzt senden. Worker-Verfügbarkeit ist unabhängig; diese Einstellung startet keine Worker und versendet nichts.'],
|
||||||
|
'verification': 'Laden Sie den Administrationsbereich neu und prüfen Sie den wirksamen Wert sowie die Vorher-/Nachher-Konfigurationshistorie. Prüfen und Senden muss weiterhin die genaue geeignete Anzahl und alle Zustellbedingungen prüfen.',
|
||||||
|
},
|
||||||
|
'campaigns.archive-encryption-governance': {
|
||||||
|
'prerequisites': [
|
||||||
|
'Policy ist verfügbar; die Richtlinienadministration darf die System-Archivrichtlinie lesen und schreiben.',
|
||||||
|
'Die handelnde Person besitzt campaigns:archive:use_legacy_zipcrypto und darf die ausgewählte Version bearbeiten.',
|
||||||
|
],
|
||||||
|
'steps': [
|
||||||
|
'Öffnen Sie aus Kampagneneinstellungen oder Anhängen die System-Archivrichtlinie unter Administration → SYSTEM → Campaign archive encryption.',
|
||||||
|
'Lassen Sie Legacy ZipCrypto ausdrücklich zu und speichern Sie. Prüfen Sie Mandanten- und Eigentümergrenzen, wenn die wirksame Kampagnenrichtlinie es weiter sperrt.',
|
||||||
|
'Kehren Sie zu Campaign zurück und laden Sie die Archivrichtlinie neu. Aktivieren Sie ZIP-Anhänge und wählen Sie Legacy ZipCrypto für das vorgesehene Archiv.',
|
||||||
|
'Bestätigen Sie die schwache Verschlüsselung, begründen Sie die Ausnahme mit mindestens 10 Zeichen und wählen Sie einen erlaubten getrennten Passwortkanal.',
|
||||||
|
'Speichern, validieren, bauen und prüfen Sie die genaue Version, bevor Sie die Zustellung getrennt freigeben.',
|
||||||
|
],
|
||||||
|
'limitations': [
|
||||||
|
'AES bleibt Standard. Legacy ist eine ausdrückliche Kompatibilitätsausnahme und niemals automatisches Fallback.',
|
||||||
|
'Untergeordnete Ebenen dürfen übergeordnete Grenzen nicht lockern; Richtlinienrechte ersetzen nicht die gesonderte Campaign-Berechtigung.',
|
||||||
|
],
|
||||||
|
'verification': 'Öffnen Sie die Anhangseinstellungen erneut und prüfen Sie Methode, erlaubende wirksame Richtlinie, getrennten Passwortkanal und begründete Bestätigung. Der Build-Nachweis muss Richtlinienhash und handelnde Person, aber kein Passwort enthalten.',
|
||||||
|
},
|
||||||
|
'campaigns.admin.collaboration-governance': {'verification': 'Testen Sie einen schreibgeschützten '
|
||||||
'Mitarbeiter, ein Poster ohne '
|
'Mitarbeiter, ein Poster ohne '
|
||||||
'Kampagnenbearbeitung und einen '
|
'Kampagnenbearbeitung und einen '
|
||||||
'Moderator; bestätigen Sie die '
|
'Moderator; bestätigen Sie die '
|
||||||
@@ -40,17 +69,13 @@ GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'campaigns.admin.co
|
|||||||
'Mail-Einstellungen.',
|
'Mail-Einstellungen.',
|
||||||
'Wählen Sie ein verfügbares Mail-Profil aus; '
|
'Wählen Sie ein verfügbares Mail-Profil aus; '
|
||||||
'Campaign speichert nur seine stabile Kennung.',
|
'Campaign speichert nur seine stabile Kennung.',
|
||||||
|
'Ist eine Migration erforderlich, wählen Sie „Ausgewähltes Mail-Profil migrieren“, auch wenn die Auswahl unverändert ist. Nutzen Sie bei gesperrten Versionen zuvor die angebotene Entsperrung oder bearbeitbare Nachfolgeversion.',
|
||||||
'Testen Sie SMTP und, wenn konfiguriert, IMAP '
|
'Testen Sie SMTP und, wenn konfiguriert, IMAP '
|
||||||
'über das Mail-Modul.',
|
'über das Mail-Modul.',
|
||||||
'Speichern, validieren und erstellen Sie die '
|
'Speichern, validieren und erstellen Sie die '
|
||||||
'Kampagne, bevor Sie die Lieferung in die '
|
'Kampagne, bevor Sie die Lieferung in die '
|
||||||
'Warteschlange stellen.'],
|
'Warteschlange stellen.'],
|
||||||
'verification': 'Öffnen Sie die Mail-Einstellungen '
|
'verification': 'Öffnen Sie die Mail-Einstellungen erneut und bestätigen Sie das Profil sowie das Ausbleiben des Migrationshinweises. Validieren, bauen und prüfen Sie anschließend. Speichern und Migration dürfen keine Zustellung auslösen.'},
|
||||||
'erneut, bestätigen Sie das ausgewählte '
|
|
||||||
'Profil, führen Sie dann die Validierung '
|
|
||||||
'aus und überprüfen Sie, ob der Build '
|
|
||||||
'ohne Profil-Drift-Fehler abgeschlossen '
|
|
||||||
'ist.'},
|
|
||||||
'campaigns.postbox-delivery': {'outcome': 'Jede aktive Zeile löst einen überprüfbaren Satz von '
|
'campaigns.postbox-delivery': {'outcome': 'Jede aktive Zeile löst einen überprüfbaren Satz von '
|
||||||
'Postbox-Zielen auf, ohne eine harte '
|
'Postbox-Zielen auf, ohne eine harte '
|
||||||
'Kampagnenabhängigkeit von Postbox einzuführen.',
|
'Kampagnenabhängigkeit von Postbox einzuführen.',
|
||||||
@@ -325,24 +350,18 @@ GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'campaigns.admin.co
|
|||||||
'prüfen Sie die Zusammenfassungen der kritischen '
|
'prüfen Sie die Zusammenfassungen der kritischen '
|
||||||
'Blocker, der Einzelüberprüfung und der '
|
'Blocker, der Einzelüberprüfung und der '
|
||||||
'Gruppenüberprüfung.',
|
'Gruppenüberprüfung.',
|
||||||
|
'Bei einem Mail-Altdatenhinweis öffnen Sie die Mail-Einstellungen dieser Version, führen die ausdrückliche Migration durch und validieren und bauen erneut.',
|
||||||
'Korrigieren Sie jeden kritischen Blocker im '
|
'Korrigieren Sie jeden kritischen Blocker im '
|
||||||
'benannten Kampagnenarbeitsbereich, validieren '
|
'benannten Kampagnenarbeitsbereich, validieren '
|
||||||
'und erstellen Sie ihn erneut.',
|
'und erstellen Sie ihn erneut.',
|
||||||
'Öffnen Sie jede verbleibende individuelle '
|
'Öffnen Sie jede verbleibende individuelle Prüfnachricht, speichern Sie die begründete Annahme und warten Sie auf die dauerhafte Bestätigung; Neuladen kann diesen Teilfortschritt fortsetzen.',
|
||||||
'Überprüfungsnachricht und notieren Sie ihre '
|
|
||||||
'Entscheidung.',
|
|
||||||
'Wenn nur nicht kritische Gruppenelemente '
|
'Wenn nur nicht kritische Gruppenelemente '
|
||||||
'verbleiben, überprüfen Sie ihre Bedingungen und '
|
'verbleiben, überprüfen Sie ihre Bedingungen und '
|
||||||
'schließen Sie die Überprüfung explizit ab.',
|
'schließen Sie die Überprüfung explizit ab.',
|
||||||
'Bestätigen Sie, dass Reviewed der gesamten '
|
'Bestätigen Sie, dass Reviewed der gesamten '
|
||||||
'erforderlichen Review entspricht und Remaining '
|
'erforderlichen Review entspricht und Remaining '
|
||||||
'vor der Lieferung Null ist.'],
|
'vor der Lieferung Null ist.'],
|
||||||
'verification': 'Überprüfen und senden Sie erneut, '
|
'verification': 'Laden Sie nach einer einzelnen Annahme vor dem vollständigen Prüfabschluss neu und bestätigen Sie, dass Begründung und Prüfstatus erhalten bleiben. Nach dem Abschluss dürfen keine kritischen Blocker oder Entscheidungen fehlen; Zustellung ist nur für dieselbe Version und denselben Build freigeschaltet.'},
|
||||||
'bestätigen Sie keinen kritischen Blocker '
|
|
||||||
'oder eine verbleibende Entscheidung und '
|
|
||||||
'überprüfen Sie, ob der zulässige '
|
|
||||||
'Liefermodus für dieselbe Version und '
|
|
||||||
'denselben Build freigeschaltet ist.'},
|
|
||||||
'campaigns.workflow.control-attachment-reuse': {'outcome': 'Ein Kampagnen-Build, dessen '
|
'campaigns.workflow.control-attachment-reuse': {'outcome': 'Ein Kampagnen-Build, dessen '
|
||||||
'wiederholte Verwendung von Anhängen '
|
'wiederholte Verwendung von Anhängen '
|
||||||
'unter einer expliziten Richtlinie '
|
'unter einer expliziten Richtlinie '
|
||||||
@@ -746,16 +765,9 @@ GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'campaigns.admin.co
|
|||||||
'durchführen.',
|
'durchführen.',
|
||||||
'Provider, Mailbox, Arbeiter und '
|
'Provider, Mailbox, Arbeiter und '
|
||||||
'Kampagnenbeweise wurden bewahrt.'],
|
'Kampagnenbeweise wurden bewahrt.'],
|
||||||
'steps': ['Klassifizieren Sie den Job und die letzten '
|
'steps': ['Öffnen Sie den Bericht der ausgewählten Version und klassifizieren Sie SMTP- und IMAP-Versuche unabhängig voneinander.',
|
||||||
'SMTP- und IMAP-Versuche unabhängig '
|
'Wiederholen Sie geeignete Fehler oder setzen Sie unversuchte Aufträge dieser Seite ausdrücklich fort; ohne Worker verwenden Sie die begrenzte Aktion Jetzt senden und prüfen verbleibende Arbeit.',
|
||||||
'voneinander.',
|
'Stellen Sie verlassene aktive Aufträge nur mit abgelaufener Sperre und nachweislich gestopptem/ersetztem Besitzer wieder her; prüfen Sie anschließend Provider- oder Postfachnachweise und gleichen Sie das ungewisse Ergebnis mit sachlicher Notiz ab.',
|
||||||
'Wiederholen Sie nur einen ausdrücklich '
|
|
||||||
'vorübergehenden, permanenten oder '
|
|
||||||
'unversuchten berechtigten Staat.',
|
|
||||||
'Überprüfen Sie für einen unbekannten Effekt '
|
|
||||||
'den Anbieter- oder Mailbox-Beweis und '
|
|
||||||
'notieren Sie den sachlichen Abgleich mit '
|
|
||||||
'einer Notiz.',
|
|
||||||
'Überprüfen Sie den resultierenden '
|
'Überprüfen Sie den resultierenden '
|
||||||
'geschützten Zustand, bevor Sie mehr Arbeit '
|
'geschützten Zustand, bevor Sie mehr Arbeit '
|
||||||
'für diesen Job zulassen.'],
|
'für diesen Job zulassen.'],
|
||||||
|
|||||||
@@ -343,6 +343,28 @@ class MailCampaignIntegration:
|
|||||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
raise MailProfileError(str(exc)) from exc
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def campaign_imap_batch(self, *, tenant_id: str, campaign_id: str) -> Iterator[Any]:
|
||||||
|
"""Use Mail-owned connection reuse when the installed capability offers it."""
|
||||||
|
delegate = self._require()
|
||||||
|
method = getattr(delegate, "campaign_imap_batch", None)
|
||||||
|
if not callable(method):
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with method(tenant_id=tenant_id, campaign_id=campaign_id) as state:
|
||||||
|
yield state
|
||||||
|
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
|
||||||
|
raise ImapAppendError(
|
||||||
|
str(exc),
|
||||||
|
temporary=getattr(exc, "temporary", None),
|
||||||
|
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||||
|
) from exc
|
||||||
|
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
|
||||||
|
raise ImapConfigurationError(str(exc)) from exc
|
||||||
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
delegate = self._require()
|
delegate = self._require()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -463,8 +463,8 @@ def _campaigns_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.27",
|
version="0.1.28",
|
||||||
workflow_definitions=campaign_workflow_definitions(module_version="0.1.27"),
|
workflow_definitions=campaign_workflow_definitions(module_version="0.1.28"),
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -727,6 +727,14 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="campaigns.admin.system-delivery", module_id="campaigns",
|
||||||
|
kind="section", label="System Campaign delivery", order=76,
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="campaigns.admin.tenant-delivery", module_id="campaigns",
|
||||||
|
kind="section", label="Tenant Campaign delivery", order=76,
|
||||||
|
),
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="campaigns.page.work",
|
id="campaigns.page.work",
|
||||||
module_id="campaigns",
|
module_id="campaigns",
|
||||||
@@ -1198,7 +1206,11 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.mail-profile-user-journey",
|
id="campaigns.mail-profile-user-journey",
|
||||||
title="Choose a Mail profile for campaign delivery",
|
title="Choose a Mail profile for campaign delivery",
|
||||||
summary="Campaigns reference an authorized Mail profile and never store SMTP/IMAP settings or credentials.",
|
summary="Campaigns reference an authorized Mail profile and never store SMTP/IMAP settings or credentials.",
|
||||||
body="Open the campaign Mail settings, select an available profile, test it through Mail, and save. Validation and delivery recheck profile authorization. A changed transport identity requires a new validation and build.",
|
body=(
|
||||||
|
"Mail settings → Reusable mail profile → SMTP credential (and IMAP credential when used) records an explicit credential identifier. An empty selection means inheritance only if Mail policy permits it; a displayed profile default is not a stored campaign choice. Missing or inactive saved profiles, servers and credentials remain visibly unavailable rather than being silently replaced. "
|
||||||
|
"Open the campaign Mail settings, select an available profile, test it through Mail, and save. If legacy campaign-local transport data is reported, use Migrate selected Mail profile after selecting an authorized profile; this explicit action also works when the existing selection is unchanged and the draft is clean. Locked historical evidence is retained: follow the version's unlock or editable-successor action before migrating. Review and send displays the migration blocker and links back to Mail settings instead of repeatedly requesting an invalid attachment preview. The profile selector loads only campaign-authorized profiles; a separate administrative policy-list failure does not empty it. Validation and delivery recheck profile authorization. Migration saves configuration but never sends mail; validate, build, and review the resulting version again before delivery."
|
||||||
|
" Mail migration and ZIP policy corrections can be saved in either order. Unchanged ZIP settings do not block migration or acquire new consent evidence. An archive or content correction with unchanged Mail references preserves the old transport server-side and retains the migration notice; no implicit migration occurs. A committed save and the following workspace refresh are separate outcomes: failed refresh keeps the last usable same-campaign/version data and displays an error. Retry Reload; obsolete responses from another campaign, version, identity or earlier refresh cannot replace the current workspace."
|
||||||
|
),
|
||||||
layer="available",
|
layer="available",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||||
@@ -1236,11 +1248,12 @@ manifest = ModuleManifest(
|
|||||||
"steps": [
|
"steps": [
|
||||||
"Open the campaign and go to Mail settings.",
|
"Open the campaign and go to Mail settings.",
|
||||||
"Select an available Mail profile; Campaign stores only its stable identifier.",
|
"Select an available Mail profile; Campaign stores only its stable identifier.",
|
||||||
|
"If migration is required, choose Migrate selected Mail profile even if the selected profile has not changed. For a locked version, first follow its supported unlock or editable-successor action.",
|
||||||
"Test SMTP and, when configured, IMAP through the Mail module.",
|
"Test SMTP and, when configured, IMAP through the Mail module.",
|
||||||
"Save, validate, and build the campaign before queueing delivery.",
|
"Save, validate, and build the campaign before queueing delivery.",
|
||||||
],
|
],
|
||||||
"outcome": "The editable campaign version references an authorized Mail-owned delivery profile without copying transport settings or credentials.",
|
"outcome": "The editable campaign version references an authorized Mail-owned delivery profile without copying transport settings or credentials.",
|
||||||
"verification": "Reopen Mail settings, confirm the selected profile, then run validation and verify that the build completes without profile-drift errors.",
|
"verification": "Reopen Mail settings and confirm the selected profile and absence of the migration notice; then validate, build, and review. Saving or migrating must not create a delivery effect.",
|
||||||
"related_topic_ids": [
|
"related_topic_ids": [
|
||||||
"campaigns.mail-profile-governance",
|
"campaigns.mail-profile-governance",
|
||||||
"campaigns.mail-profile-operations",
|
"campaigns.mail-profile-operations",
|
||||||
@@ -1252,7 +1265,11 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.mail-profile-governance",
|
id="campaigns.mail-profile-governance",
|
||||||
title="Govern Campaign-to-Mail profile references",
|
title="Govern Campaign-to-Mail profile references",
|
||||||
summary="Mail owns transport definitions and encrypted credentials; Campaign owns only the selected profile reference and delivery evidence.",
|
summary="Mail owns transport definitions and encrypted credentials; Campaign owns only the selected profile reference and delivery evidence.",
|
||||||
body="Grant mail:profile:use to campaign authors, constrain profile availability through Mail policy, and keep effective credential inheritance enabled. Inline transport fields are rejected. Legacy records remain unchanged until an explicit, audited profile migration creates or updates an editable version.",
|
body=(
|
||||||
|
"Grant mail:profile:use to campaign authors, constrain profile availability through Mail policy, and explicitly configure whether profile credentials may be inherited or a campaign must select a Mail-owned credential. SMTP/IMAP credential inheritance controls appear on the Mail policy page, with local, inherited and effective values and ancestor locks. Inline transport fields are rejected and are never returned with credentials to the browser. Legacy transport remains unchanged until an explicit, audited profile migration creates or updates an editable version; selecting the same already referenced profile is sufficient when the operator uses Migrate selected Mail profile. Campaign Mail settings request only the usable campaign-scoped profile list; administrative Mail policy enumeration is a separate request on the Mail policy page and its errors remain local to that surface. This separation does not grant profile administration or bypass owner, tenant, or Mail authorization. Migration never sends mail or restores an old execution snapshot."
|
||||||
|
" Independent draft corrections may retain the exact stored legacy server object only while public Mail references remain unchanged and no inline transport is submitted. This saves content without selecting or using a profile, even if its authorization was revoked. Explicit migration still requires mail:profile:use and current Mail policy. Version-save audit details distinguish legacy_mail_settings_preserved from legacy_mail_settings_migrated. Unchanged ZIP configuration is not re-acknowledged; modified ZIP settings retain full policy checks. Successful repair saves invalidate prior build/execution evidence and do not relax validation, review or delivery."
|
||||||
|
" Already migrated drafts follow the same unchanged-selection rule: a later requirement for explicit SMTP/IMAP credentials does not prevent saving unrelated archive or content corrections. Changing any selected Mail resource still enforces Mail permission and current policy, and authoritative validation/delivery always recheck the full selection."
|
||||||
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
||||||
@@ -1300,7 +1317,7 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.mail-profile-operations",
|
id="campaigns.mail-profile-operations",
|
||||||
title="Operate profile-backed campaign delivery",
|
title="Operate profile-backed campaign delivery",
|
||||||
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
|
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
|
||||||
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Synchronous Mail batches preflight DNS, connectivity, TLS, and authentication before their first effect, reuse a bounded healthy SMTP connection, and reconnect before a later message when the old connection is stale. Review and send shows batch, connection, reconnect, failure, and pause counts. A systemic authentication, sender, or connectivity failure pauses remaining queued jobs with a stable reason code; correct and test the Mail profile before explicitly resuming. A connection loss after DATA begins stays outcome-unknown and is never replayed automatically. Preserve a stopped record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
body="SMTP and IMAP runtime actions enforce credential-selection requirements independently for their own protocol; SMTP calls do not need IMAP parameters and vice versa. Full campaign validation and build summaries continue checking both required selections. Preflight distinguishes Mail profile/credential policy from SMTP configuration, authentication, and connectivity failures; a successful server connection test does not replace campaign authorization. A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Synchronous Mail batches preflight DNS, connectivity, TLS, and authentication before their first effect, reuse a bounded healthy SMTP connection, and reconnect before a later message when the old connection is stale. Review and send shows batch, connection, reconnect, failure, and pause counts. A systemic authentication, sender, or connectivity failure pauses remaining queued jobs with a stable reason code; correct and test the Mail profile before explicitly resuming. A connection loss after DATA begins stays outcome-unknown and is never replayed automatically. Preserve a stopped record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
||||||
@@ -1348,7 +1365,10 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.workflow.prepare-validate-and-build",
|
id="campaigns.workflow.prepare-validate-and-build",
|
||||||
title="Prepare, validate, and build a campaign",
|
title="Prepare, validate, and build a campaign",
|
||||||
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
||||||
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. Summary metrics expose a named drill-down only when an authorized source collection, filtered review table, attachment preview, or report helps the user inspect and act on the count. Privacy-suppressed aggregate reports remain non-interactive because an unsuppressed subgroup would violate their disclosure boundary. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
|
body=(
|
||||||
|
"Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. Summary metrics expose a named drill-down only when an authorized source collection, filtered review table, attachment preview, or report helps the user inspect and act on the count. Privacy-suppressed aggregate reports remain non-interactive because an unsuppressed subgroup would violate their disclosure boundary. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace."
|
||||||
|
" In individual and global address dialogs, the up/down actions set the saved address order. Dialog Save applies that order to the campaign draft without alphabetically sorting it; duplicate email addresses retain their first position. Pasted addresses append in their entered order without rearranging existing addresses. The first individual To address remains the primary name/email shown in the recipient row. Save the campaign page to persist the updated draft; a failed page save retains the order for an explicit retry. Dialog Cancel deliberately discards only its unconfirmed edits."
|
||||||
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("campaign_manager", "campaign_author"),
|
audience=("campaign_manager", "campaign_author"),
|
||||||
@@ -1424,6 +1444,8 @@ manifest = ModuleManifest(
|
|||||||
summary="Use AES by default and select weak Windows-compatible ZipCrypto only with explicit policy, permission, acknowledgement, and evidence.",
|
summary="Use AES by default and select weak Windows-compatible ZipCrypto only with explicit policy, permission, acknowledgement, and evidence.",
|
||||||
body=(
|
body=(
|
||||||
"Campaign resolves archive encryption through Policy across system, tenant, owner user or group, and campaign scopes. Password-protected archives use AES unless the complete inherited policy permits Legacy ZipCrypto — Windows-compatible, weak encryption and the actor has campaigns:archive:use_legacy_zipcrypto. A legacy selection requires a reasoned acknowledgement. Passwords are never included in Campaign evidence or the campaign message and must be conveyed through the separately selected, policy-allowed channel. Each build freezes the archive and member hashes, implementation version, policy hash and source path, acknowledgement actor, reason and time, and build identity. A more restrictive later policy blocks queueing and sending until the campaign is rebuilt; Campaign never falls back from AES to ZipCrypto after an error. Temporary plaintext and archive material is confined to the bounded build directory and removed after success or failure."
|
"Campaign resolves archive encryption through Policy across system, tenant, owner user or group, and campaign scopes. Password-protected archives use AES unless the complete inherited policy permits Legacy ZipCrypto — Windows-compatible, weak encryption and the actor has campaigns:archive:use_legacy_zipcrypto. A legacy selection requires a reasoned acknowledgement. Passwords are never included in Campaign evidence or the campaign message and must be conveyed through the separately selected, policy-allowed channel. Each build freezes the archive and member hashes, implementation version, policy hash and source path, acknowledgement actor, reason and time, and build identity. A more restrictive later policy blocks queueing and sending until the campaign is rebuilt; Campaign never falls back from AES to ZipCrypto after an error. Temporary plaintext and archive material is confined to the bounded build directory and removed after success or failure."
|
||||||
|
" Campaign Settings, Policies, and Attachments show the effective policy and direct authorized administrators to Administration → SYSTEM → Campaign archive encryption. Enable Legacy ZipCrypto there and Save; fresh system defaults are editable without creating an override merely by opening the page. Tenant and owner policies may still narrow the result. Back in Campaign, Reload archive policy, select Legacy ZipCrypto under Attachments → ZIP attachments, acknowledge its weak encryption, and give an operational reason of at least 10 characters. Policy unavailability keeps Legacy blocked. Neither a policy save nor an attachment configuration save sends mail."
|
||||||
|
" Mail migration and archive corrections can be saved independently in either order. An exact unchanged ZIP configuration is retained without re-acknowledgement during an unrelated save, even after policy or permission revocation; it is not authorized for use. Any modified ZIP configuration must satisfy the current methods, password-delivery channels and legacy permission/acknowledgement requirements. An archive correction with unchanged public Mail references retains the exact legacy transport server-side until an explicit authorized migration. No client may introduce or echo inline transport, change Mail references under this exception, or fabricate acknowledgement evidence. Both repairs invalidate execution/build evidence; validation, review, building and delivery remain fail-closed until all conditions are satisfied."
|
||||||
),
|
),
|
||||||
documentation_types=("user", "admin"),
|
documentation_types=("user", "admin"),
|
||||||
audience=("campaign_manager", "campaign_reviewer", "policy_admin"),
|
audience=("campaign_manager", "campaign_reviewer", "policy_admin"),
|
||||||
@@ -1443,13 +1465,36 @@ manifest = ModuleManifest(
|
|||||||
"route": "/campaigns/{campaign_id}/files",
|
"route": "/campaigns/{campaign_id}/files",
|
||||||
"screen": "Campaign attachments",
|
"screen": "Campaign attachments",
|
||||||
"help_contexts": ["campaign.archive-encryption"],
|
"help_contexts": ["campaign.archive-encryption"],
|
||||||
|
"prerequisites": [
|
||||||
|
"Policy is available; a policy administrator can read and write the system archive policy.",
|
||||||
|
"The Campaign actor has campaigns:archive:use_legacy_zipcrypto and may edit the selected version.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"From Campaign Settings or Attachments, open the system archive policy under Administration → SYSTEM → Campaign archive encryption.",
|
||||||
|
"Explicitly permit Legacy ZipCrypto and Save; inspect tenant and owner restrictions if the effective Campaign policy still blocks it.",
|
||||||
|
"Return to Campaign and Reload archive policy; enable ZIP attachments and select Legacy ZipCrypto for the intended archive.",
|
||||||
|
"Acknowledge weak encryption, provide a reason of at least 10 characters, and choose an allowed separate password-delivery channel.",
|
||||||
|
"Save, validate, build, and review the exact version before separately authorizing delivery.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"AES remains the default; Legacy is an explicit compatibility exception, never an automatic fallback.",
|
||||||
|
"Child scopes cannot loosen a parent ceiling, and policy permission does not replace the dedicated Campaign permission.",
|
||||||
|
],
|
||||||
|
"verification": "Reopen the attachment settings and confirm the chosen method, allowed effective policy, separate password channel, and reasoned acknowledgment. Confirm build evidence records the policy hash and actor without the password.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.workflow.complete-review",
|
id="campaigns.workflow.complete-review",
|
||||||
title="Inspect built messages and complete review",
|
title="Inspect built messages and complete review",
|
||||||
summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
|
summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
|
||||||
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Use the explicit actions on actionable recipient, attachment, validation, and review metrics to reveal the corresponding source page, evidence preview, or filtered built-message table. Informational and privacy-suppressed measures do not become hidden click targets. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
|
body=(
|
||||||
|
"Opening Template without editing, changing read-only state, or switching visual/source inspection preserves saved HTML and does not mark the page dirty or require a save when leaving. Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Ordinary editor saves send only client-owned created_from, field_overrides, and opt_ins metadata; review_send and approval_gate are readable server evidence, not writable editor payloads. Omitting that evidence during a metadata save preserves it on the server; supported unlock, fork, and build invalidation rules still clear stale evidence when required. If the selected version requires legacy Mail migration, Review and send stays read-only, suppresses incompatible attachment-preview requests, and offers Open Mail settings with the exact selected version. Resolve the migration, validate, build, and review before sending. Use the explicit actions on actionable recipient, attachment, validation, and review metrics to reveal the corresponding source page, evidence preview, or filtered built-message table. Informational and privacy-suppressed measures do not become hidden click targets. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again."
|
||||||
|
" Accept similar review conditions groups only server-eligible, unreviewed messages from the currently loaded matching set. Choose one human-readable category, inspect the counted recipient selection and provide one shared reason when accepting attachment exceptions. Each request names at most 200 exact messages and verifies the current build and category; repeat for the remaining messages rather than assuming other categories or unloaded messages were included. Each selected message receives its own frozen, attributable decision evidence. A failed save retains the reason and selection for explicit retry, and a changed build blocks stale acceptance. Group acceptance neither sends messages nor completes the final review gate. Hard blockers cannot be overridden. Deliberate policy exclusions and explicitly allowed zero-match attachment rules remain informational and do not require review decisions."
|
||||||
|
" Saving an individual acceptance persists its reason and reviewed state immediately, before full review completion; reload resumes acknowledged progress for the same build. A failed or conflicting save retains the pending reason for explicit retry and does not mark the message reviewed. Each save merges only selected messages, preserving other reviewers' evidence, without rebuilding messages, inspecting attachment files, or reloading the entire workspace. Partial progress never authorizes delivery; final completion still checks every required decision and hard blocker. Required attachment and hard-block policies remain stronger than an optional rule allowing empty matches; the separate campaign policy for sending an entirely attachment-free message also remains authoritative."
|
||||||
|
" Review saves require campaign review permission, the current version revision, and a safe operational review_build_token; diagnostic scope is not required. Stale builds or concurrent changes return a conflict without overwriting stored progress."
|
||||||
|
" Accepted or expected attachment conditions remain satisfied for the same build in Confirm and send; missing/ambiguous source counts remain visible for context but create no second acceptance gate. The reviewed-stage mock uses verified frozen messages and completed decisions instead of regenerating them. Changed inputs, issue evidence, message bytes or Mail transport stop the test before mock capture; authoring previews retain their separate transient-build behavior."
|
||||||
|
" Validation details and repeated-file lists expose every item through the shared DataGrid pagination controls. A related missing-rule cause and attachment-free policy outcome are explained together while expandable technical evidence remains intact. Built-message filters and rows use four operational states: Ready, Needs review, Blocked and Excluded. Accepted explicit decisions become Ready; warnings awaiting acknowledgment remain Needs review. A second column explains the state. These are presentation changes, not deletion or rewriting of frozen issues or audit evidence."
|
||||||
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("campaign_reviewer",),
|
audience=("campaign_reviewer",),
|
||||||
@@ -1486,13 +1531,14 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Open Review and send and inspect the Critical blockers, Individual review, and Group review summaries.",
|
"Open Review and send and inspect the Critical blockers, Individual review, and Group review summaries.",
|
||||||
|
"If a legacy Mail migration notice appears, use Open Mail settings for this version, complete the explicit migration, and validate and build again.",
|
||||||
"Correct every critical blocker in the named campaign workspace, then validate and build again.",
|
"Correct every critical blocker in the named campaign workspace, then validate and build again.",
|
||||||
"Open each remaining individual review message and record its decision.",
|
"Open each remaining individual review message, save its reasoned acceptance and wait for the durable acknowledgement; reload can resume this partial progress.",
|
||||||
"When only non-critical group items remain, review their conditions and explicitly complete review.",
|
"When only non-critical group items remain, review their conditions and explicitly complete review.",
|
||||||
"Confirm that Reviewed equals the required review total and Remaining is zero before delivery.",
|
"Confirm that Reviewed equals the required review total and Remaining is zero before delivery.",
|
||||||
],
|
],
|
||||||
"outcome": "Review evidence for the exact current build, with no unresolved blocker or review decision.",
|
"outcome": "Review evidence for the exact current build, with no unresolved blocker or review decision.",
|
||||||
"verification": "Reload Review and send, confirm no critical blocker or remaining decision, and verify that the permitted delivery mode is unlocked for the same version and build.",
|
"verification": "Reload after an individual acceptance before completing the whole review and confirm its reason and reviewed state persist. After final completion, confirm no critical blocker or remaining decision and verify that delivery is unlocked only for the same version and build.",
|
||||||
"related_topic_ids": [
|
"related_topic_ids": [
|
||||||
"campaigns.workflow.prepare-validate-and-build",
|
"campaigns.workflow.prepare-validate-and-build",
|
||||||
"campaigns.workflow.retry-and-reconcile",
|
"campaigns.workflow.retry-and-reconcile",
|
||||||
@@ -1503,7 +1549,9 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.workflow.retry-and-reconcile",
|
id="campaigns.workflow.retry-and-reconcile",
|
||||||
title="Retry only known failures and reconcile uncertain effects",
|
title="Retry only known failures and reconcile uncertain effects",
|
||||||
summary="Keep safe-to-retry failures separate from Mail, Postbox, or IMAP effects whose outcome is unknown.",
|
summary="Keep safe-to-retry failures separate from Mail, Postbox, or IMAP effects whose outcome is unknown.",
|
||||||
body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown Mail, Postbox, or IMAP effect. Inspect external evidence and reconcile the affected channel before continuing. Accepted Mail attempts and accepted Postbox targets are immutable during partial retries, and repairing Sent never resends accepted Mail.",
|
body=("A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown Mail, Postbox, or IMAP effect. Inspect external evidence and reconcile the affected channel before continuing. Accepted Mail attempts and accepted Postbox targets are immutable during partial retries, and repairing Sent never resends accepted Mail. "
|
||||||
|
"Without workers, Report offers explicit bounded retry and continuation using the same immutable jobs, execution checks, review evidence, approvals, Mail authorization, rate limits and recovery ledger as Send now. Each request is capped by the effective synchronous policy and returns remaining work; repeating continuation skips already accepted, excluded, active and uncertain jobs. Retry requires campaigns:campaign:retry plus campaigns:campaign:send for inline execution; continuation requires campaigns:campaign:queue plus campaigns:campaign:send. Reconciliation needs campaigns:campaign:reconcile and a factual evidence note; it never sends mail. "
|
||||||
|
"A stalled claimed/sending/appending job is not safe merely because time elapsed. Report exposes Recover interrupted claim only for an expired durable lease whose original runtime is proven stopped or replaced. The submitted opaque revision must still match and the original recovery evidence must be valid; the action changes the effect only to outcome-unknown. Inspect provider/mailbox evidence and separately reconcile accepted/not sent or appended/not appended before an explicit retry. Missing original leases/evidence and unconfirmed owners remain blocked for operator investigation. A duplicate worker task leaves active state unchanged."),
|
||||||
layer="evidence",
|
layer="evidence",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("campaign_sender", "campaign_operator"),
|
audience=("campaign_sender", "campaign_operator"),
|
||||||
@@ -1548,9 +1596,9 @@ manifest = ModuleManifest(
|
|||||||
"Provider, mailbox, worker, and campaign evidence has been preserved.",
|
"Provider, mailbox, worker, and campaign evidence has been preserved.",
|
||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Classify the job and latest SMTP and IMAP attempts independently.",
|
"Open the selected version's Report and classify each job's SMTP and IMAP attempts independently.",
|
||||||
"Retry only an explicitly temporary, permanent-with-override, or unattempted eligible state.",
|
"Explicitly retry eligible failures or continue the unattempted jobs on this page; without workers, use the bounded Send now action and inspect remaining work.",
|
||||||
"For an unknown effect, inspect provider or mailbox evidence and record the factual reconciliation with a note.",
|
"For an abandoned active claim, recover only with expired-lease and stopped/replaced-owner proof; then inspect provider or mailbox evidence and reconcile the unknown effect with a factual note.",
|
||||||
"Verify the resulting protected state before allowing more work for that job.",
|
"Verify the resulting protected state before allowing more work for that job.",
|
||||||
],
|
],
|
||||||
"outcome": "Every investigated job is either protected as effected, explicitly retryable, or still visibly unresolved.",
|
"outcome": "Every investigated job is either protected as effected, explicitly retryable, or still visibly unresolved.",
|
||||||
@@ -1561,11 +1609,35 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.admin.delivery-policy",
|
||||||
|
title="Configure the interactive Campaign delivery limit",
|
||||||
|
summary="Set an audited system maximum for Send now and optional narrower tenant limits without changing any campaign or sending messages.",
|
||||||
|
body="Administration → SYSTEM → Campaign delivery permits system:settings:read to inspect and system:settings:write to save the synchronous recipient-job maximum. Its unchanged default is 25; an administrator may explicitly choose 0–500, for example 200 for a 183-job run. Administration → TENANT → Campaign delivery requires admin:policies:read/write and may only narrow the inherited system limit. Clearing an override restores inheritance. An explicitly set GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS remains an authoritative deployment ceiling, including zero. With no deployment value, the implicit default does not prevent an authorized system override. Larger interactive batches take longer and may exceed proxy/request timeouts; background workers remain the preferred separate mode for large campaigns and require CELERY_ENABLED plus working Redis/Celery infrastructure. This setting limits one exact persisted eligible Send now run, not campaign size or worker dispatch. Save stores only this scoped setting with revision conflict protection, before/after configuration history and audit; it neither sends messages, alters saved review evidence, nor bypasses Mail, review, approval or execution-integrity gates. Failed saves retain the draft. Reload uses the shared unsaved-change guard and returns fresh saved policy.",
|
||||||
|
layer="configured", documentation_types=("admin",), audience=("administrator", "platform_operator"), order=51,
|
||||||
|
conditions=(DocumentationCondition(required_modules=("campaigns",), any_scopes=("system:settings:read", "admin:policies:read")),),
|
||||||
|
links=(DocumentationLink(label="System Campaign delivery", href="/admin?section=system-campaign-delivery", kind="runtime"),
|
||||||
|
DocumentationLink(label="Tenant Campaign delivery", href="/admin?section=tenant-campaign-delivery", kind="runtime")),
|
||||||
|
metadata={"kind": "workflow", "route": "/admin?section=system-campaign-delivery", "screen": "Campaign delivery",
|
||||||
|
"prerequisites": ["You hold the read and write permissions for the intended system or active tenant scope."],
|
||||||
|
"steps": ["Open Administration and select Campaign delivery in SYSTEM or TENANT.",
|
||||||
|
"Inspect the saved, inherited and maximum permitted values; disable inheritance to set a whole-number override.",
|
||||||
|
"Save and verify the saved effective limit, or retain the draft and resolve an explicit conflict before retrying.",
|
||||||
|
"Return to Review and send and reload delivery options before separately choosing a delivery action."],
|
||||||
|
"limitations": ["Tenant settings cannot raise system policy; an explicit deployment ceiling cannot be raised through this UI.",
|
||||||
|
"Zero disables Send now. Worker availability is independent; changing this setting never starts workers or sends messages."],
|
||||||
|
"verification": "Reload the administration section and confirm the effective value, then inspect before/after configuration history. Review and send must still enforce the exact eligible count and all delivery gates."},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.reference.composition-assurance",
|
id="campaigns.reference.composition-assurance",
|
||||||
title="Assure the Campaign reference composition",
|
title="Assure the Campaign reference composition",
|
||||||
summary="Release Campaign only with aligned contracts, role-safe surfaces, durable effect evidence, optional-module isolation, and recoverable data.",
|
summary="Release Campaign only with aligned contracts, role-safe surfaces, durable effect evidence, optional-module isolation, and recoverable data.",
|
||||||
body="Campaign is a reference composition only when Core, Mail, Files, Addresses, workers, storage, policies, and documentation are tested in the exact installed combination. Normal readers see business state rather than paths, storage keys, worker claims, or raw provider diagnostics; diagnostic and export authority remain separate.",
|
body=(
|
||||||
|
"Campaign is a reference composition only when Core, Mail, Files, Addresses, workers, storage, policies, and documentation are tested in the exact installed combination. Normal readers see business state rather than paths, storage keys, worker claims, or raw provider diagnostics; diagnostic and export authority remain separate."
|
||||||
|
" Release checks must initialize Campaign validation and attachment resolution independently in fresh processes, without relying on an earlier page or test import. These local entry points remain usable without installing Mail or Files; their import never starts delivery or relaxes managed-file path authorization."
|
||||||
|
" Verify that individual review reasons and reviewed state survive reload before final completion. Incremental review saves require campaigns:campaign:review and write access, merge exact selected current-build jobs under revision checks, and audit each acceptance without replacing other reviewers' evidence. The operational review_build_token does not expose raw diagnostic tokens. Partial progress never enables delivery, and hard blockers cannot be accepted. Policy-driven exclusions and explicitly allowed empty optional attachment rules create no new review obligation; required/global hard blocks remain enforced. These resolution changes apply to new builds only: an intentional rebuild is needed to reclassify existing messages and invalidates prior review and approval evidence. Frozen historical job issues must not be rewritten from mutable policy."
|
||||||
|
" The opt-in mock-send use_reviewed_build mode requires an existing sealed execution and same-build completed review when review is needed. It checks persisted job/issue input seals, EML length/digest/Message-ID and current Mail transport before any mock capture or requested mailbox clear. It never sends SMTP, alters Campaign delivery state, or creates a missing legacy snapshot. include_needs_review is not a blanket override in this mode."
|
||||||
|
),
|
||||||
layer="evidence",
|
layer="evidence",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=(
|
audience=(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import UTC, datetime
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import String, and_, cast, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.orm.exc import StaleDataError
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
|
|
||||||
@@ -28,8 +29,11 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
||||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
campaign_editor_state_for_edit,
|
campaign_editor_state_for_edit,
|
||||||
|
campaign_editor_state_with_client_update,
|
||||||
campaign_mail_profile_boundary_violations,
|
campaign_mail_profile_boundary_violations,
|
||||||
campaign_mail_profile_id,
|
campaign_mail_profile_id,
|
||||||
|
campaign_mail_references_unchanged,
|
||||||
|
campaign_preserves_legacy_mail_settings,
|
||||||
assert_campaign_uses_mail_profile_reference,
|
assert_campaign_uses_mail_profile_reference,
|
||||||
public_campaign_mail_server,
|
public_campaign_mail_server,
|
||||||
validate_campaign_editor_state,
|
validate_campaign_editor_state,
|
||||||
@@ -41,6 +45,7 @@ from govoplan_campaign.backend.persistence.campaigns import (
|
|||||||
normalize_campaign_paths,
|
normalize_campaign_paths,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||||
|
from govoplan_campaign.backend.services.review_decisions import review_decision_metadata
|
||||||
|
|
||||||
|
|
||||||
class LockedCampaignVersionError(CampaignPersistenceError):
|
class LockedCampaignVersionError(CampaignPersistenceError):
|
||||||
@@ -701,6 +706,12 @@ def _updated_runtime_json(
|
|||||||
campaign_mail_profile_boundary_violations(version.raw_json)
|
campaign_mail_profile_boundary_violations(version.raw_json)
|
||||||
)
|
)
|
||||||
if requires_migration and not migrate_legacy_mail_settings:
|
if requires_migration and not migrate_legacy_mail_settings:
|
||||||
|
if campaign_preserves_legacy_mail_settings(version.raw_json, runtime_json):
|
||||||
|
# The browser only knows the sanitized reference. Keep the exact
|
||||||
|
# stored transport here; never infer migration from its omission.
|
||||||
|
# No Mail resource is being selected/used by this content repair.
|
||||||
|
runtime_json["server"] = copy.deepcopy(version.raw_json["server"])
|
||||||
|
return runtime_json
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
||||||
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
|
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
|
||||||
@@ -712,6 +723,11 @@ def _updated_runtime_json(
|
|||||||
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
|
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
|
||||||
"Select a Mail profile before saving."
|
"Select a Mail profile before saving."
|
||||||
)
|
)
|
||||||
|
if not migrate_legacy_mail_settings and campaign_mail_references_unchanged(version.raw_json, runtime_json):
|
||||||
|
# Retaining a selection is not selecting or using a Mail resource. An
|
||||||
|
# unrelated repair must remain saveable after credential policy changes;
|
||||||
|
# validation/build/delivery still reauthorize the complete selection.
|
||||||
|
return runtime_json
|
||||||
mail_integration().assert_campaign_mail_policy_allows_json(
|
mail_integration().assert_campaign_mail_policy_allows_json(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -745,7 +761,9 @@ def _apply_version_field_updates(
|
|||||||
if value is not None:
|
if value is not None:
|
||||||
setattr(version, field_name, value)
|
setattr(version, field_name, value)
|
||||||
if editor_state is not None:
|
if editor_state is not None:
|
||||||
version.editor_state = validate_campaign_editor_state(editor_state)
|
version.editor_state = campaign_editor_state_with_client_update(
|
||||||
|
version.editor_state, editor_state
|
||||||
|
)
|
||||||
if autosave:
|
if autosave:
|
||||||
version.autosaved_at = datetime.now(UTC)
|
version.autosaved_at = datetime.now(UTC)
|
||||||
|
|
||||||
@@ -908,6 +926,10 @@ def update_campaign_review_state(
|
|||||||
reviewed_message_keys: list[str],
|
reviewed_message_keys: list[str],
|
||||||
issue_decisions: list[dict[str, Any]] | None = None,
|
issue_decisions: list[dict[str, Any]] | None = None,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
merge_progress: bool = False,
|
||||||
|
expected_build_token: str | None = None,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
decision_category_key: str | None = None,
|
||||||
commit: bool = True,
|
commit: bool = True,
|
||||||
) -> CampaignVersion:
|
) -> CampaignVersion:
|
||||||
"""Persist review acknowledgement without mutating the locked campaign data.
|
"""Persist review acknowledgement without mutating the locked campaign data.
|
||||||
@@ -930,21 +952,64 @@ def update_campaign_review_state(
|
|||||||
"Delivery has started; message review state can no longer be changed."
|
"Delivery has started; message review state can no longer be changed."
|
||||||
)
|
)
|
||||||
build_token = _campaign_review_build_token(version)
|
build_token = _campaign_review_build_token(version)
|
||||||
|
if merge_progress and (expected_build_token is None or expected_revision is None):
|
||||||
|
raise CampaignPersistenceError("Incremental review requires the current build token and revision.")
|
||||||
|
if expected_build_token is not None and expected_build_token not in {build_token, version.review_build_token}:
|
||||||
|
raise LockedCampaignVersionError("The message build changed. Reload the current build before recording review decisions.")
|
||||||
|
if expected_revision is not None and version.edit_revision != expected_revision:
|
||||||
|
raise RevisionConflictError(
|
||||||
|
resource_type="campaign_version", resource_id=version.id,
|
||||||
|
current_revision=version.edit_revision, submitted_base_revision=expected_revision,
|
||||||
|
refresh_path=f"/api/v1/campaigns/{campaign_id}/versions/{version.id}",
|
||||||
|
current_etag=version.strong_etag,
|
||||||
|
)
|
||||||
normalized_reviewed = list(
|
normalized_reviewed = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
str(value) for value in reviewed_message_keys if str(value).strip()
|
str(value) for value in reviewed_message_keys if str(value).strip()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
normalized_decisions: list[dict[str, Any]] = []
|
requested = issue_decisions or []
|
||||||
|
if merge_progress and not inspection_complete and (len(normalized_reviewed) > 1_000 or len(requested) > 1_000):
|
||||||
|
raise CampaignPersistenceError("Save review progress in groups of at most 1000 messages.")
|
||||||
|
previous = (version.editor_state or {}).get("review_send", {})
|
||||||
|
previous = previous if isinstance(previous, dict) and previous.get("build_token") == build_token else {}
|
||||||
|
previous_decisions = [item for item in previous.get("issue_decisions", []) if isinstance(item, dict)]
|
||||||
|
if merge_progress:
|
||||||
|
normalized_reviewed = list(dict.fromkeys([*previous.get("reviewed_message_keys", []), *normalized_reviewed]))
|
||||||
|
merged_decisions = {str(item.get("job_id")): item for item in previous_decisions}
|
||||||
|
# Do not silently collapse duplicate client decisions; reject them below.
|
||||||
|
submitted_ids = [str(item.get("job_id") or "") for item in requested]
|
||||||
|
if len(submitted_ids) != len(set(submitted_ids)):
|
||||||
|
raise CampaignPersistenceError("Only one review decision may be recorded per built message.")
|
||||||
|
merged_decisions.update({str(item.get("job_id")): item for item in requested})
|
||||||
|
else:
|
||||||
|
merged_decisions = {}
|
||||||
if inspection_complete:
|
if inspection_complete:
|
||||||
normalized_reviewed, normalized_decisions = _complete_campaign_review(
|
normalized_reviewed, normalized_decisions = _complete_campaign_review(
|
||||||
session,
|
session,
|
||||||
version,
|
version,
|
||||||
normalized_reviewed,
|
normalized_reviewed,
|
||||||
issue_decisions or [],
|
list(merged_decisions.values()) if merge_progress else requested,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
build_token=build_token,
|
build_token=build_token,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
# A progress save inspects only explicitly submitted rows. In particular,
|
||||||
|
# it does not rebuild messages, resolve files or load every built body.
|
||||||
|
selected_keys = set(str(value) for value in reviewed_message_keys if str(value).strip())
|
||||||
|
jobs = _selected_review_jobs(session, version.id, selected_keys, requested)
|
||||||
|
_assert_review_selection(jobs, selected_keys, requested, decision_category_key)
|
||||||
|
decisions = _normalize_review_issue_decisions(jobs, requested, user_id=user_id, build_token=build_token)
|
||||||
|
normalized_reviewed = list(dict.fromkeys([*normalized_reviewed, *(item["review_key"] for item in decisions)]))
|
||||||
|
if merge_progress:
|
||||||
|
# Retain other reviewers' evidence verbatim; only submitted decisions
|
||||||
|
# may replace their own job's reason/evidence.
|
||||||
|
merged_decisions = {str(item.get("job_id")): item for item in previous_decisions}
|
||||||
|
merged_decisions.update({item["job_id"]: item for item in decisions})
|
||||||
|
normalized_decisions = list(merged_decisions.values())
|
||||||
|
else:
|
||||||
|
normalized_decisions = decisions
|
||||||
|
normalized_decisions = _preserve_unchanged_review_evidence(normalized_decisions, previous_decisions)
|
||||||
_write_campaign_review_state(
|
_write_campaign_review_state(
|
||||||
version,
|
version,
|
||||||
build_token=build_token,
|
build_token=build_token,
|
||||||
@@ -961,6 +1026,43 @@ def update_campaign_review_state(
|
|||||||
return version
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_review_jobs(session, version_id, keys, requested) -> list[CampaignJob]:
|
||||||
|
ids = [str(item.get("job_id") or "") for item in requested]
|
||||||
|
if not keys and not ids:
|
||||||
|
return []
|
||||||
|
return session.query(CampaignJob).filter(
|
||||||
|
CampaignJob.campaign_version_id == version_id,
|
||||||
|
or_(
|
||||||
|
CampaignJob.id.in_(ids), CampaignJob.entry_id.in_(keys),
|
||||||
|
and_(or_(CampaignJob.entry_id.is_(None), CampaignJob.entry_id == ""), cast(CampaignJob.entry_index, String).in_(keys)),
|
||||||
|
),
|
||||||
|
).order_by(CampaignJob.entry_index.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_review_selection(jobs, keys, requested, category_key) -> None:
|
||||||
|
available_keys = {str(job.entry_id or job.entry_index) for job in jobs}
|
||||||
|
if not keys.issubset(available_keys):
|
||||||
|
raise CampaignPersistenceError("A reviewed message references a message outside the current build.")
|
||||||
|
if any(job.build_status != "built" or job.validation_status in {"blocked", "excluded", "inactive"} for job in jobs):
|
||||||
|
raise CampaignPersistenceError("Only built, non-blocked delivery messages can be accepted for review.")
|
||||||
|
if any(any(isinstance(issue, dict) and str(issue.get("behavior") or "").lower() == "block" for issue in (job.issues_snapshot or [])) for job in jobs):
|
||||||
|
raise CampaignPersistenceError("Hard-blocking issues cannot be overridden by a review decision.")
|
||||||
|
if category_key is not None:
|
||||||
|
selected_ids = {str(item.get("job_id") or "") for item in requested}
|
||||||
|
if not selected_ids or any(review_decision_metadata(job)["category_key"] != category_key for job in jobs if job.id in selected_ids):
|
||||||
|
raise CampaignPersistenceError("The selected messages no longer share the requested review category.")
|
||||||
|
|
||||||
|
|
||||||
|
def _preserve_unchanged_review_evidence(decisions, previous) -> list[dict[str, Any]]:
|
||||||
|
prior_by_id = {item.get("job_id"): item for item in previous}
|
||||||
|
result = []
|
||||||
|
for decision in decisions:
|
||||||
|
prior = prior_by_id.get(decision.get("job_id"))
|
||||||
|
fields = ("decision", "reason", "build_token", "message_sha256", "issue_fingerprint", "review_key")
|
||||||
|
result.append(copy.deepcopy(prior) if prior and all(prior.get(key) == decision.get(key) for key in fields) else decision)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _campaign_review_build_token(version: CampaignVersion) -> str:
|
def _campaign_review_build_token(version: CampaignVersion) -> str:
|
||||||
build_summary = (
|
build_summary = (
|
||||||
version.build_summary if isinstance(version.build_summary, dict) else {}
|
version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||||
@@ -997,7 +1099,9 @@ def _complete_campaign_review(
|
|||||||
blocking = [
|
blocking = [
|
||||||
job
|
job
|
||||||
for job in jobs
|
for job in jobs
|
||||||
if job.build_status != "built" or job.validation_status == "blocked"
|
if job.validation_status == "blocked"
|
||||||
|
or (job.build_status != "built" and job.validation_status not in {"excluded", "inactive"})
|
||||||
|
or any(isinstance(issue, dict) and str(issue.get("behavior") or "").lower() == "block" for issue in (job.issues_snapshot or []))
|
||||||
]
|
]
|
||||||
if blocking:
|
if blocking:
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
@@ -1039,7 +1143,7 @@ def _normalize_review_issue_decisions(
|
|||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"A review decision references a message outside the current build."
|
"A review decision references a message outside the current build."
|
||||||
)
|
)
|
||||||
if jobs_by_id[job_id].validation_status != "needs_review":
|
if not review_decision_metadata(jobs_by_id[job_id])["eligible"]:
|
||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"Review decisions are accepted only for messages requiring review."
|
"Review decisions are accepted only for messages requiring review."
|
||||||
)
|
)
|
||||||
@@ -1054,6 +1158,10 @@ def _normalize_review_issue_decisions(
|
|||||||
timestamp = (decided_at or datetime.now(UTC)).isoformat()
|
timestamp = (decided_at or datetime.now(UTC)).isoformat()
|
||||||
normalized: list[dict[str, Any]] = []
|
normalized: list[dict[str, Any]] = []
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
|
if job.validation_status != "needs_review":
|
||||||
|
# Excluded jobs may retain the issues that triggered the deliberate
|
||||||
|
# drop. That evidence is not a request to override their exclusion.
|
||||||
|
continue
|
||||||
reviewable_issues = [
|
reviewable_issues = [
|
||||||
issue
|
issue
|
||||||
for issue in (job.issues_snapshot or [])
|
for issue in (job.issues_snapshot or [])
|
||||||
@@ -1134,7 +1242,7 @@ def _bulk_acceptable_review_keys(jobs: list[CampaignJob]) -> list[str]:
|
|||||||
return [
|
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 == "warning"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ _SYNCHRONOUS_POLICY_KEYS = (
|
|||||||
"source",
|
"source",
|
||||||
"deployment_max_recipient_jobs",
|
"deployment_max_recipient_jobs",
|
||||||
"tenant_max_recipient_jobs",
|
"tenant_max_recipient_jobs",
|
||||||
|
"system_max_recipient_jobs",
|
||||||
|
"deployment_ceiling_explicit",
|
||||||
)
|
)
|
||||||
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
|
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
|
||||||
_BUILD_SUMMARY_KEYS = (
|
_BUILD_SUMMARY_KEYS = (
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from sqlalchemy.orm import Session
|
|||||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
CAMPAIGN_MAIL_SERVER_KEYS,
|
CAMPAIGN_MAIL_SERVER_KEYS,
|
||||||
campaign_mail_profile_id,
|
campaign_mail_profile_id,
|
||||||
|
campaign_mail_references_unchanged,
|
||||||
|
campaign_preserves_legacy_mail_settings,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.archive_encryption import (
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
CampaignArchiveEncryptionError,
|
CampaignArchiveEncryptionError,
|
||||||
@@ -458,7 +460,18 @@ def _update_campaign_version_detail_response(
|
|||||||
),
|
),
|
||||||
detail=str(exc),
|
detail=str(exc),
|
||||||
) from exc
|
) from exc
|
||||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
preserves_legacy_mail = (
|
||||||
|
not payload.migrate_legacy_mail_settings
|
||||||
|
and campaign_preserves_legacy_mail_settings(
|
||||||
|
current_version.raw_json, payload.campaign_json
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unchanged_mail_selection = (
|
||||||
|
not payload.migrate_legacy_mail_settings
|
||||||
|
and campaign_mail_references_unchanged(current_version.raw_json, payload.campaign_json)
|
||||||
|
)
|
||||||
|
if not unchanged_mail_selection:
|
||||||
|
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||||
try:
|
try:
|
||||||
result = _campaign_version_detail_response(
|
result = _campaign_version_detail_response(
|
||||||
session,
|
session,
|
||||||
@@ -499,6 +512,7 @@ def _update_campaign_version_detail_response(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||||
|
"legacy_mail_settings_preserved": preserves_legacy_mail,
|
||||||
"legacy_zipcrypto_acknowledgements": acknowledgements,
|
"legacy_zipcrypto_acknowledgements": acknowledgements,
|
||||||
},
|
},
|
||||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from govoplan_campaign.backend.routes.assignments import router as assignments_r
|
|||||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||||
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
||||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||||
|
from govoplan_campaign.backend.routes.delivery_settings import router as delivery_settings_router
|
||||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||||
@@ -18,6 +19,7 @@ from govoplan_campaign.backend.routes.versions import router as versions_router
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
for workflow_router in (
|
for workflow_router in (
|
||||||
|
delivery_settings_router,
|
||||||
operations_router,
|
operations_router,
|
||||||
transfers_router,
|
transfers_router,
|
||||||
campaigns_router,
|
campaigns_router,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
CampaignSendJobRequest,
|
CampaignSendJobRequest,
|
||||||
CampaignSendUnattemptedRequest,
|
CampaignSendUnattemptedRequest,
|
||||||
CampaignResolveOutcomeRequest,
|
CampaignResolveOutcomeRequest,
|
||||||
|
CampaignRecoverClaimRequest,
|
||||||
CampaignDeliveryOptionsResponse,
|
CampaignDeliveryOptionsResponse,
|
||||||
MockCampaignSendRequest,
|
MockCampaignSendRequest,
|
||||||
MockCampaignSendResponse,
|
MockCampaignSendResponse,
|
||||||
@@ -84,6 +85,32 @@ router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{campaign_id}/delivery-progress")
|
||||||
|
def get_campaign_delivery_progress(
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str | None = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||||
|
):
|
||||||
|
from govoplan_campaign.backend.services.delivery_progress import campaign_delivery_progress
|
||||||
|
|
||||||
|
_get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
try:
|
||||||
|
return campaign_delivery_progress(session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||||
|
except QueueingError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _public_recovery_result(result: dict) -> dict:
|
||||||
|
if not result.get("run_inline"):
|
||||||
|
return result
|
||||||
|
public = public_send_campaign_now_result(result, validation_summary={}, build_summary={})
|
||||||
|
for key in ("action", "selected_count", "remaining_count", "enqueued_count", "skipped", "run_inline"):
|
||||||
|
if key in result:
|
||||||
|
public[key] = result[key]
|
||||||
|
return public
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse
|
"/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse
|
||||||
)
|
)
|
||||||
@@ -242,6 +269,8 @@ def retry_campaign_jobs(
|
|||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
_require_permission(principal, "campaigns:recipient:read")
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
payload = payload or CampaignRetryJobsRequest()
|
payload = payload or CampaignRetryJobsRequest()
|
||||||
|
if payload.run_inline:
|
||||||
|
_require_permission(principal, "campaigns:campaign:send")
|
||||||
_require_campaign_profile_use_if_needed(
|
_require_campaign_profile_use_if_needed(
|
||||||
session, principal, campaign_id, payload.version_id
|
session, principal, campaign_id, payload.version_id
|
||||||
)
|
)
|
||||||
@@ -255,6 +284,7 @@ def retry_campaign_jobs(
|
|||||||
include_permanent=payload.include_permanent,
|
include_permanent=payload.include_permanent,
|
||||||
force_max_attempts=payload.force_max_attempts,
|
force_max_attempts=payload.force_max_attempts,
|
||||||
enqueue_celery=payload.enqueue_celery,
|
enqueue_celery=payload.enqueue_celery,
|
||||||
|
run_inline=payload.run_inline,
|
||||||
dry_run=payload.dry_run,
|
dry_run=payload.dry_run,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
@@ -265,10 +295,10 @@ def retry_campaign_jobs(
|
|||||||
else "campaign.jobs_retry_dry_run",
|
else "campaign.jobs_retry_dry_run",
|
||||||
object_type="campaign",
|
object_type="campaign",
|
||||||
object_id=campaign_id,
|
object_id=campaign_id,
|
||||||
details=result,
|
details=_public_recovery_result(result),
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return CampaignActionResponse(result=result)
|
return CampaignActionResponse(result=_public_recovery_result(result))
|
||||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||||
@@ -287,6 +317,8 @@ def send_unattempted_campaign_jobs(
|
|||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
_require_permission(principal, "campaigns:recipient:read")
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
payload = payload or CampaignSendUnattemptedRequest()
|
payload = payload or CampaignSendUnattemptedRequest()
|
||||||
|
if payload.run_inline:
|
||||||
|
_require_permission(principal, "campaigns:campaign:send")
|
||||||
_require_campaign_profile_use_if_needed(
|
_require_campaign_profile_use_if_needed(
|
||||||
session, principal, campaign_id, payload.version_id
|
session, principal, campaign_id, payload.version_id
|
||||||
)
|
)
|
||||||
@@ -298,6 +330,7 @@ def send_unattempted_campaign_jobs(
|
|||||||
version_id=payload.version_id,
|
version_id=payload.version_id,
|
||||||
job_ids=payload.job_ids or None,
|
job_ids=payload.job_ids or None,
|
||||||
enqueue_celery=payload.enqueue_celery,
|
enqueue_celery=payload.enqueue_celery,
|
||||||
|
run_inline=payload.run_inline,
|
||||||
dry_run=payload.dry_run,
|
dry_run=payload.dry_run,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
@@ -308,10 +341,10 @@ def send_unattempted_campaign_jobs(
|
|||||||
else "campaign.unattempted_jobs_dry_run",
|
else "campaign.unattempted_jobs_dry_run",
|
||||||
object_type="campaign",
|
object_type="campaign",
|
||||||
object_id=campaign_id,
|
object_id=campaign_id,
|
||||||
details=result,
|
details=_public_recovery_result(result),
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
return CampaignActionResponse(result=result)
|
return CampaignActionResponse(result=_public_recovery_result(result))
|
||||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||||
@@ -387,6 +420,34 @@ def send_single_campaign_job_endpoint(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/jobs/{job_id}/recover-claim", response_model=CampaignActionResponse)
|
||||||
|
def recover_campaign_job_claim(
|
||||||
|
campaign_id: str,
|
||||||
|
job_id: str,
|
||||||
|
payload: CampaignRecoverClaimRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")),
|
||||||
|
):
|
||||||
|
from govoplan_campaign.backend.services.delivery_recovery import recover_stale_delivery_claim, RecoveryStateConflict
|
||||||
|
|
||||||
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
|
try:
|
||||||
|
result = recover_stale_delivery_claim(
|
||||||
|
session, tenant_id=principal.tenant_id, campaign_id=campaign_id,
|
||||||
|
job_id=job_id, channel=payload.channel,
|
||||||
|
expected_revision=payload.expected_revision, note=payload.note,
|
||||||
|
)
|
||||||
|
audit_from_principal(session, principal, action="campaign.job_claim_recovered", object_type="campaign_job", object_id=job_id, details=result, commit=True)
|
||||||
|
return CampaignActionResponse(result=result)
|
||||||
|
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=409 if isinstance(exc, RecoveryStateConflict) else 422, detail=str(exc)) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{campaign_id}/jobs/{job_id}/resolve-outcome",
|
"/{campaign_id}/jobs/{job_id}/resolve-outcome",
|
||||||
response_model=CampaignActionResponse,
|
response_model=CampaignActionResponse,
|
||||||
@@ -440,8 +501,9 @@ def mock_send_campaign(
|
|||||||
):
|
):
|
||||||
"""Run a fully visible mock delivery flow without mutating campaign state.
|
"""Run a fully visible mock delivery flow without mutating campaign state.
|
||||||
|
|
||||||
The route validates and builds the selected version, then optionally records
|
Authoring previews validate and build transiently; reviewed-build mode
|
||||||
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
|
verifies frozen jobs/EML and completed review instead. Both optionally record
|
||||||
|
mock SMTP deliveries and mock IMAP appends. Neither talks to the configured
|
||||||
real SMTP/IMAP servers and it does not mark the version sent/final.
|
real SMTP/IMAP servers and it does not mark the version sent/final.
|
||||||
"""
|
"""
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
@@ -460,6 +522,7 @@ def mock_send_campaign(
|
|||||||
send=payload.send,
|
send=payload.send,
|
||||||
include_warnings=payload.include_warnings,
|
include_warnings=payload.include_warnings,
|
||||||
include_needs_review=payload.include_needs_review,
|
include_needs_review=payload.include_needs_review,
|
||||||
|
use_reviewed_build=payload.use_reviewed_build,
|
||||||
append_sent=payload.append_sent,
|
append_sent=payload.append_sent,
|
||||||
clear_mailbox=payload.clear_mailbox,
|
clear_mailbox=payload.clear_mailbox,
|
||||||
check_files=payload.check_files,
|
check_files=payload.check_files,
|
||||||
@@ -475,6 +538,7 @@ def mock_send_campaign(
|
|||||||
details={
|
details={
|
||||||
"version_id": result.get("version_id"),
|
"version_id": result.get("version_id"),
|
||||||
"send_requested": payload.send,
|
"send_requested": payload.send,
|
||||||
|
"use_reviewed_build": payload.use_reviewed_build,
|
||||||
"sent_count": result.get("send", {}).get("sent_count"),
|
"sent_count": result.get("send", {}).get("sent_count"),
|
||||||
"failed_count": result.get("send", {}).get("failed_count"),
|
"failed_count": result.get("send", {}).get("failed_count"),
|
||||||
},
|
},
|
||||||
@@ -703,30 +767,16 @@ def append_sent(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||||
):
|
):
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||||
payload = payload or AppendSentRequest()
|
payload = payload or AppendSentRequest()
|
||||||
version_ids = {
|
selected_version_id = payload.version_id or campaign.current_version_id
|
||||||
row[0]
|
_require_campaign_profile_use_if_needed(session, principal, campaign_id, selected_version_id)
|
||||||
for row in session.query(CampaignJob.campaign_version_id)
|
|
||||||
.filter(
|
|
||||||
CampaignJob.tenant_id == principal.tenant_id,
|
|
||||||
CampaignJob.campaign_id == campaign_id,
|
|
||||||
CampaignJob.send_status.in_(
|
|
||||||
[JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value]
|
|
||||||
),
|
|
||||||
CampaignJob.imap_status.in_(
|
|
||||||
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
.all()
|
|
||||||
}
|
|
||||||
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
|
||||||
try:
|
try:
|
||||||
result = enqueue_pending_imap_appends(
|
result = enqueue_pending_imap_appends(
|
||||||
session,
|
session,
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
campaign_id=campaign_id,
|
campaign_id=campaign_id,
|
||||||
|
version_id=selected_version_id,
|
||||||
enqueue_celery=payload.enqueue_celery,
|
enqueue_celery=payload.enqueue_celery,
|
||||||
run_inline=payload.run_inline,
|
run_inline=payload.run_inline,
|
||||||
dry_run=payload.dry_run,
|
dry_run=payload.dry_run,
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Audited, independently editable delivery limits; never a delivery command."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, StrictInt
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.admin.models import SystemSettings
|
||||||
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||||
|
from govoplan_core.core.configuration_control import (
|
||||||
|
ConfigurationControlError, configuration_value_digest,
|
||||||
|
ensure_configuration_change_allowed, record_configuration_change_applied,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
from govoplan_campaign.backend.delivery_policy import (
|
||||||
|
ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||||
|
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||||
|
CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY, SYNCHRONOUS_SEND_MAX_SETTINGS_KEY,
|
||||||
|
CampaignDeliveryPolicyError, effective_synchronous_send_policy,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.route_support import _require_permission
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/campaigns/settings/delivery-policy", tags=["campaigns"])
|
||||||
|
Scope = Literal["system", "tenant"]
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryPolicyUpdate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
synchronous_send_max_recipients: StrictInt | None = Field(default=None, ge=0, le=500)
|
||||||
|
expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _state(session: Session, principal: ApiPrincipal, scope: Scope) -> dict:
|
||||||
|
policy = effective_synchronous_send_policy(session, tenant_id=principal.tenant_id, apply_tenant_override=scope == "tenant")
|
||||||
|
system_limit = policy.system_max_recipient_jobs
|
||||||
|
# Resolve the parent without projecting the tenant's own override into it.
|
||||||
|
parent_limit = min(policy.deployment_max_recipient_jobs, system_limit) if system_limit is not None else (
|
||||||
|
policy.deployment_max_recipient_jobs if policy.deployment_ceiling_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS
|
||||||
|
)
|
||||||
|
own = system_limit if scope == "system" else policy.tenant_max_recipient_jobs
|
||||||
|
system = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
|
||||||
|
tenant = session.get(Tenant, principal.tenant_id) if scope == "tenant" else None
|
||||||
|
def stored_revision(row):
|
||||||
|
return ((row.settings or {}).get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY) or {}).get("revision", 0) if row else 0
|
||||||
|
revision = configuration_value_digest({
|
||||||
|
"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None,
|
||||||
|
"own": own, "system": system_limit, "deployment": policy.deployment_max_recipient_jobs,
|
||||||
|
"explicit_deployment": policy.deployment_ceiling_explicit,
|
||||||
|
"system_revision": stored_revision(system), "tenant_revision": stored_revision(tenant),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"scope": scope, "synchronous_send_max_recipients": own, "revision": revision,
|
||||||
|
"max_configurable_recipients": policy.deployment_max_recipient_jobs if scope == "system" else parent_limit,
|
||||||
|
"effective_max_recipients": parent_limit if scope == "system" else policy.max_recipient_jobs,
|
||||||
|
"inherited_max_recipients": (policy.deployment_max_recipient_jobs if policy.deployment_ceiling_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS) if scope == "system" else parent_limit,
|
||||||
|
"absolute_max_recipients": ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||||
|
"deployment_ceiling_explicit": policy.deployment_ceiling_explicit,
|
||||||
|
"deployment_max_recipients": policy.deployment_max_recipient_jobs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_permission(principal: ApiPrincipal, scope: Scope, operation: str) -> None:
|
||||||
|
_require_permission(principal, f"system:settings:{operation}" if scope == "system" else f"admin:policies:{operation}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{scope}")
|
||||||
|
def read_delivery_policy(
|
||||||
|
scope: Scope, session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope("system:settings:read", "admin:policies:read")),
|
||||||
|
):
|
||||||
|
_scope_permission(principal, scope, "read")
|
||||||
|
try:
|
||||||
|
return _state(session, principal, scope)
|
||||||
|
except CampaignDeliveryPolicyError as exc:
|
||||||
|
raise HTTPException(422, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{scope}")
|
||||||
|
def update_delivery_policy(
|
||||||
|
scope: Scope, payload: DeliveryPolicyUpdate, session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_any_scope("system:settings:write", "admin:policies:write")),
|
||||||
|
):
|
||||||
|
_scope_permission(principal, scope, "write")
|
||||||
|
try:
|
||||||
|
# Always lock in the same order; history and system policy share one Core row.
|
||||||
|
system = session.query(SystemSettings).filter(SystemSettings.id == SYSTEM_SETTINGS_ID).populate_existing().with_for_update().one_or_none()
|
||||||
|
if system is None:
|
||||||
|
system = get_system_settings(session)
|
||||||
|
target = system
|
||||||
|
if scope == "tenant":
|
||||||
|
target = session.query(Tenant).filter(Tenant.id == principal.tenant_id).populate_existing().with_for_update().one_or_none()
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(404, detail="Tenant not found")
|
||||||
|
before = _state(session, principal, scope)
|
||||||
|
if payload.expected_revision != before["revision"]:
|
||||||
|
raise HTTPException(409, detail="Campaign delivery policy changed. Reload the saved policy before retrying; your draft has not been saved.")
|
||||||
|
value = payload.synchronous_send_max_recipients
|
||||||
|
if value is not None and value > before["max_configurable_recipients"]:
|
||||||
|
raise HTTPException(422, detail=f"This scope may configure at most {before['max_configurable_recipients']} recipient jobs; inherited or explicit deployment ceilings cannot be raised here.")
|
||||||
|
key = f"campaign_delivery_policy.{scope}"
|
||||||
|
after_value = {SYNCHRONOUS_SEND_MAX_SETTINGS_KEY: value}
|
||||||
|
approval = ensure_configuration_change_allowed(
|
||||||
|
session, key=key, value=after_value, actor_user_id=principal.user.id,
|
||||||
|
actor_scopes=tuple(principal.scopes), target={"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None},
|
||||||
|
)
|
||||||
|
settings = dict(target.settings or {})
|
||||||
|
saved_policy = dict(settings.get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY) or {})
|
||||||
|
saved_policy["revision"] = int(saved_policy.get("revision") or 0) + 1
|
||||||
|
if value is None:
|
||||||
|
saved_policy.pop(SYNCHRONOUS_SEND_MAX_SETTINGS_KEY, None)
|
||||||
|
else:
|
||||||
|
saved_policy[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY] = value
|
||||||
|
settings[CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY] = saved_policy
|
||||||
|
target.settings = settings
|
||||||
|
session.flush()
|
||||||
|
record_configuration_change_applied(
|
||||||
|
session, key=key, before_value={SYNCHRONOUS_SEND_MAX_SETTINGS_KEY: before[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY]},
|
||||||
|
after_value=after_value, actor_user_id=principal.user.id, approval=approval,
|
||||||
|
target={"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None},
|
||||||
|
audit_event="campaign.delivery_policy_updated",
|
||||||
|
)
|
||||||
|
result = _state(session, principal, scope)
|
||||||
|
audit_from_principal(session, principal, action="campaign.delivery_policy_updated", scope=scope, object_type="campaign_delivery_policy",
|
||||||
|
object_id=scope if scope == "system" else principal.tenant_id,
|
||||||
|
details={"scope": scope, "before": before[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY], "after": value, "effective_max_recipients": result["effective_max_recipients"]}, commit=False)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
except (CampaignDeliveryPolicyError, ConfigurationControlError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(422, detail=str(exc)) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
@@ -48,6 +48,7 @@ from govoplan_campaign.backend.services.job_queries import (
|
|||||||
_job_attempts_payload,
|
_job_attempts_payload,
|
||||||
_calendar_invitations_for_jobs,
|
_calendar_invitations_for_jobs,
|
||||||
_job_detail_payload,
|
_job_detail_payload,
|
||||||
|
_job_page_recovery_metadata,
|
||||||
_job_diagnostics_payload,
|
_job_diagnostics_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -425,6 +426,7 @@ def get_job_detail(
|
|||||||
return CampaignJobDetailResponse(
|
return CampaignJobDetailResponse(
|
||||||
job=_job_detail_payload(
|
job=_job_detail_payload(
|
||||||
job,
|
job,
|
||||||
|
recovery=_job_page_recovery_metadata(session, [job]).get(job.id),
|
||||||
calendar_invitation=_calendar_invitations_for_jobs(
|
calendar_invitation=_calendar_invitations_for_jobs(
|
||||||
session,
|
session,
|
||||||
[job],
|
[job],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from urllib.parse import quote
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.exc import StaleDataError
|
||||||
|
|
||||||
from govoplan_campaign.backend.schemas import (
|
from govoplan_campaign.backend.schemas import (
|
||||||
BuildCampaignRequest,
|
BuildCampaignRequest,
|
||||||
@@ -23,6 +24,7 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||||
from govoplan_core.audit.logging import audit_from_principal
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
from govoplan_core.core.object_storage import StorageBackendError
|
from govoplan_core.core.object_storage import StorageBackendError
|
||||||
|
from govoplan_core.core.concurrency import RevisionConflictError
|
||||||
from govoplan_core.core.recovery import (
|
from govoplan_core.core.recovery import (
|
||||||
RecoveryGuaranteeError,
|
RecoveryGuaranteeError,
|
||||||
RecoveryMode,
|
RecoveryMode,
|
||||||
@@ -581,6 +583,10 @@ def set_version_review_state(
|
|||||||
for item in payload.issue_decisions
|
for item in payload.issue_decisions
|
||||||
],
|
],
|
||||||
user_id=principal.user.id,
|
user_id=principal.user.id,
|
||||||
|
merge_progress=payload.merge_progress,
|
||||||
|
expected_build_token=payload.build_token,
|
||||||
|
expected_revision=payload.base_revision,
|
||||||
|
decision_category_key=payload.decision_category_key,
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
@@ -592,9 +598,17 @@ def set_version_review_state(
|
|||||||
details={
|
details={
|
||||||
"campaign_id": campaign_id,
|
"campaign_id": campaign_id,
|
||||||
"inspection_complete": payload.inspection_complete,
|
"inspection_complete": payload.inspection_complete,
|
||||||
|
"merge_progress": payload.merge_progress,
|
||||||
|
"build_token": payload.build_token,
|
||||||
|
"base_revision": payload.base_revision,
|
||||||
|
"result_revision": version.edit_revision,
|
||||||
"reviewed_message_count": len(payload.reviewed_message_keys),
|
"reviewed_message_count": len(payload.reviewed_message_keys),
|
||||||
"issue_decision_count": len(payload.issue_decisions),
|
"issue_decision_count": len(payload.issue_decisions),
|
||||||
"issue_decisions": _review_decision_audit_evidence(version),
|
"issue_decisions": _review_decision_audit_evidence(
|
||||||
|
version,
|
||||||
|
job_ids={item.job_id for item in payload.issue_decisions}
|
||||||
|
if payload.merge_progress and not payload.inspection_complete else None,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
@@ -602,6 +616,12 @@ def set_version_review_state(
|
|||||||
version,
|
version,
|
||||||
context=_campaign_response_context(principal),
|
context=_campaign_response_context(principal),
|
||||||
)
|
)
|
||||||
|
except RevisionConflictError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=exc.as_dict()) from exc
|
||||||
|
except StaleDataError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Review progress changed concurrently. Reload before saving this decision again.") from exc
|
||||||
except LockedCampaignVersionError as exc:
|
except LockedCampaignVersionError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1021,13 +1041,18 @@ def _archive_encryption_audit_evidence(value: object) -> dict[str, object]:
|
|||||||
|
|
||||||
def _review_decision_audit_evidence(
|
def _review_decision_audit_evidence(
|
||||||
version: CampaignVersion,
|
version: CampaignVersion,
|
||||||
|
*,
|
||||||
|
job_ids: set[str] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||||
review_state = editor_state.get("review_send")
|
review_state = editor_state.get("review_send")
|
||||||
if not isinstance(review_state, dict):
|
if not isinstance(review_state, dict):
|
||||||
return {}
|
return {}
|
||||||
raw_decisions = review_state.get("issue_decisions")
|
raw_decisions = review_state.get("issue_decisions")
|
||||||
decisions = [item for item in raw_decisions or [] if isinstance(item, dict)]
|
decisions = [
|
||||||
|
item for item in raw_decisions or []
|
||||||
|
if isinstance(item, dict) and (job_ids is None or str(item.get("job_id")) in job_ids)
|
||||||
|
]
|
||||||
evidence = [
|
evidence = [
|
||||||
{
|
{
|
||||||
"decision": item.get("decision"),
|
"decision": item.get("decision"),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from pydantic import (
|
|||||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
public_campaign_editor_state,
|
public_campaign_editor_state,
|
||||||
|
campaign_review_reference,
|
||||||
validate_campaign_editor_state,
|
validate_campaign_editor_state,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.response_security import (
|
from govoplan_campaign.backend.response_security import (
|
||||||
@@ -470,12 +471,22 @@ class CampaignReviewStateRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
inspection_complete: bool = False
|
inspection_complete: bool = False
|
||||||
reviewed_message_keys: list[str] = Field(default_factory=list)
|
merge_progress: bool = False
|
||||||
|
build_token: str | None = Field(default=None, min_length=1, max_length=256)
|
||||||
|
base_revision: int | None = Field(default=None, ge=1)
|
||||||
|
decision_category_key: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
reviewed_message_keys: list[str] = Field(default_factory=list, max_length=100_000)
|
||||||
issue_decisions: list[CampaignReviewDecisionRequest] = Field(
|
issue_decisions: list[CampaignReviewDecisionRequest] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
max_length=100_000,
|
max_length=100_000,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_progress_preconditions(self):
|
||||||
|
if self.merge_progress and (self.build_token is None or self.base_revision is None):
|
||||||
|
raise ValueError("Incremental review requires the current build_token and base_revision.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class CampaignPartialValidationRequest(BaseModel):
|
class CampaignPartialValidationRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
@@ -491,6 +502,7 @@ class CampaignVersionResponse(BaseModel):
|
|||||||
campaign_id: str
|
campaign_id: str
|
||||||
version_number: int
|
version_number: int
|
||||||
edit_revision: int = 1
|
edit_revision: int = 1
|
||||||
|
review_build_token: str | None = None
|
||||||
strong_etag: str = ""
|
strong_etag: str = ""
|
||||||
schema_version: str
|
schema_version: str
|
||||||
source_filename: str | None = None
|
source_filename: str | None = None
|
||||||
@@ -524,10 +536,15 @@ class CampaignVersionResponse(BaseModel):
|
|||||||
def remove_unsupported_editor_state(
|
def remove_unsupported_editor_state(
|
||||||
cls, value: Any, info: ValidationInfo
|
cls, value: Any, info: ValidationInfo
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return public_campaign_editor_state(
|
result = public_campaign_editor_state(
|
||||||
value,
|
value,
|
||||||
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
|
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
|
||||||
)
|
)
|
||||||
|
if isinstance(value, dict) and isinstance(value.get("review_send"), dict) and isinstance(result.get("review_send"), dict):
|
||||||
|
result["review_send"]["review_build_token"] = campaign_review_reference(
|
||||||
|
str(info.data.get("id") or ""), value["review_send"].get("build_token")
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
@field_validator("source_filename", mode="before")
|
@field_validator("source_filename", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -1093,6 +1110,7 @@ class CampaignRetryJobsRequest(BaseModel):
|
|||||||
include_permanent: bool = False
|
include_permanent: bool = False
|
||||||
force_max_attempts: bool = False
|
force_max_attempts: bool = False
|
||||||
enqueue_celery: bool = True
|
enqueue_celery: bool = True
|
||||||
|
run_inline: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -1102,6 +1120,7 @@ class CampaignSendUnattemptedRequest(BaseModel):
|
|||||||
version_id: str | None = None
|
version_id: str | None = None
|
||||||
job_ids: list[str] = Field(default_factory=list)
|
job_ids: list[str] = Field(default_factory=list)
|
||||||
enqueue_celery: bool = True
|
enqueue_celery: bool = True
|
||||||
|
run_inline: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -1146,6 +1165,21 @@ class CampaignResolveOutcomeRequest(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignRecoverClaimRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
channel: Literal["smtp", "imap"]
|
||||||
|
expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||||
|
note: str = Field(min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_evidence(self):
|
||||||
|
self.note = self.note.strip()
|
||||||
|
if not self.note:
|
||||||
|
raise ValueError("Claim recovery requires an evidence note")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ValidateCampaignRequest(BaseModel):
|
class ValidateCampaignRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -1278,6 +1312,7 @@ class MockCampaignSendRequest(BaseModel):
|
|||||||
send: bool = False
|
send: bool = False
|
||||||
include_warnings: bool = True
|
include_warnings: bool = True
|
||||||
include_needs_review: bool = False
|
include_needs_review: bool = False
|
||||||
|
use_reviewed_build: bool = False
|
||||||
append_sent: bool = True
|
append_sent: bool = True
|
||||||
clear_mailbox: bool = False
|
clear_mailbox: bool = False
|
||||||
check_files: bool = False
|
check_files: bool = False
|
||||||
@@ -1290,6 +1325,7 @@ class MockCampaignSendResponse(BaseModel):
|
|||||||
class AppendSentRequest(BaseModel):
|
class AppendSentRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
version_id: str | None = None
|
||||||
enqueue_celery: bool = True
|
enqueue_celery: bool = True
|
||||||
run_inline: bool = False
|
run_inline: bool = False
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass, replace
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email import policy
|
from email import policy
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
@@ -284,6 +284,9 @@ class AppendSentResult:
|
|||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
folder: str | None = None
|
folder: str | None = None
|
||||||
message: str | None = None
|
message: str | None = None
|
||||||
|
connection_sequence: int | None = None
|
||||||
|
session_reused: bool = False
|
||||||
|
reconnect_count: int = 0
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -293,6 +296,9 @@ class AppendSentResult:
|
|||||||
"dry_run": self.dry_run,
|
"dry_run": self.dry_run,
|
||||||
"folder": self.folder,
|
"folder": self.folder,
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
|
"connection_sequence": self.connection_sequence,
|
||||||
|
"session_reused": self.session_reused,
|
||||||
|
"reconnect_count": self.reconnect_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1074,6 +1080,32 @@ def send_campaign_now(
|
|||||||
# Repeat the hard bound against the post-queue set. This closes the window
|
# Repeat the hard bound against the post-queue set. This closes the window
|
||||||
# where a concurrent queue operation could otherwise enlarge an immediate
|
# where a concurrent queue operation could otherwise enlarge an immediate
|
||||||
# run between the initial decision and the first provider effect.
|
# run between the initial decision and the first provider effect.
|
||||||
|
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
|
||||||
|
return _send_synchronous_job_batch(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
jobs=jobs,
|
||||||
|
synchronous_policy=synchronous_policy,
|
||||||
|
skipped_count=queue_result.skipped_count + queue_result.blocked_count,
|
||||||
|
use_rate_limit=use_rate_limit,
|
||||||
|
enqueue_imap_task=enqueue_imap_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_synchronous_job_batch(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
version: CampaignVersion,
|
||||||
|
jobs: list[CampaignJob],
|
||||||
|
synchronous_policy: SynchronousSendPolicy,
|
||||||
|
skipped_count: int = 0,
|
||||||
|
use_rate_limit: bool = True,
|
||||||
|
enqueue_imap_task: bool = False,
|
||||||
|
) -> SendCampaignNowResult:
|
||||||
|
"""Shared immutable-job execution for initial delivery and explicit recovery."""
|
||||||
|
|
||||||
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
|
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
|
||||||
delivery_contexts = _preflight_synchronous_send_batch(
|
delivery_contexts = _preflight_synchronous_send_batch(
|
||||||
session,
|
session,
|
||||||
@@ -1095,8 +1127,10 @@ def send_campaign_now(
|
|||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
contexts=delivery_contexts,
|
contexts=delivery_contexts,
|
||||||
)
|
)
|
||||||
|
batch_entered = False
|
||||||
try:
|
try:
|
||||||
with batch_manager as smtp_batch:
|
with batch_manager as smtp_batch:
|
||||||
|
batch_entered = True
|
||||||
# Queue state becomes durable only after local and SMTP
|
# Queue state becomes durable only after local and SMTP
|
||||||
# DNS/connectivity/TLS/auth preflight succeeds.
|
# DNS/connectivity/TLS/auth preflight succeeds.
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -1116,6 +1150,8 @@ def send_campaign_now(
|
|||||||
sent_count += 1
|
sent_count += 1
|
||||||
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
||||||
outcome_unknown_count += 1
|
outcome_unknown_count += 1
|
||||||
|
elif result.status in {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value, "failed"}:
|
||||||
|
failed_count += 1
|
||||||
else:
|
else:
|
||||||
skipped_after_queue += 1
|
skipped_after_queue += 1
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1143,9 +1179,39 @@ def send_campaign_now(
|
|||||||
smtp_reconnect_count = int(getattr(smtp_batch, "reconnect_count", 0) or 0)
|
smtp_reconnect_count = int(getattr(smtp_batch, "reconnect_count", 0) or 0)
|
||||||
except (MailProfileError, SmtpConfigurationError, SmtpSendError, OSError) as exc:
|
except (MailProfileError, SmtpConfigurationError, SmtpSendError, OSError) as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
reason_code = str(getattr(exc, "reason_code", "") or "smtp_batch_preflight_failed")
|
if batch_entered:
|
||||||
|
# This catch also covers batch teardown. Once entry succeeded it
|
||||||
|
# must not relabel a later error as a no-effect preflight failure.
|
||||||
|
raise SendJobError(
|
||||||
|
"Synchronous delivery was interrupted after SMTP preflight. "
|
||||||
|
"Messages may already have been sent. Inspect the Campaign report "
|
||||||
|
"and resolve uncertain outcomes before retrying."
|
||||||
|
) from exc
|
||||||
|
if isinstance(exc, MailProfileError):
|
||||||
|
reason_code = "mail_profile_preflight_failed"
|
||||||
|
explanation = (
|
||||||
|
"Campaign delivery preflight was blocked by the selected Mail profile, "
|
||||||
|
"credential selection, or effective Mail policy. Check the campaign's "
|
||||||
|
"Mail settings and authorized references"
|
||||||
|
)
|
||||||
|
elif isinstance(exc, SmtpConfigurationError):
|
||||||
|
reason_code = "smtp_configuration_preflight_failed"
|
||||||
|
explanation = (
|
||||||
|
"Campaign delivery preflight could not use the selected SMTP configuration. "
|
||||||
|
"Check its server, credentials, and outbound connection policy"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
explanations = {
|
||||||
|
"smtp_authentication_failed": "SMTP authentication failed for the campaign's selected credential",
|
||||||
|
"smtp_preflight_rejected": "The SMTP server rejected the campaign's connection preflight",
|
||||||
|
"smtp_connectivity_unavailable": "SMTP preflight could not establish DNS, connectivity, or TLS",
|
||||||
|
}
|
||||||
|
reason_code = getattr(exc, "reason_code", None)
|
||||||
|
if reason_code not in explanations:
|
||||||
|
reason_code = "smtp_connectivity_unavailable"
|
||||||
|
explanation = explanations[reason_code]
|
||||||
raise SynchronousSendRejected(
|
raise SynchronousSendRejected(
|
||||||
"SMTP batch preflight could not validate DNS, connectivity, TLS, and authentication; no message was sent.",
|
f"{explanation}; no message was sent.",
|
||||||
reason=reason_code,
|
reason=reason_code,
|
||||||
eligible_count=len(jobs),
|
eligible_count=len(jobs),
|
||||||
policy=synchronous_policy,
|
policy=synchronous_policy,
|
||||||
@@ -1158,9 +1224,7 @@ def send_campaign_now(
|
|||||||
sent_count=sent_count,
|
sent_count=sent_count,
|
||||||
failed_count=failed_count,
|
failed_count=failed_count,
|
||||||
outcome_unknown_count=outcome_unknown_count,
|
outcome_unknown_count=outcome_unknown_count,
|
||||||
skipped_count=queue_result.skipped_count
|
skipped_count=skipped_count + skipped_after_queue,
|
||||||
+ queue_result.blocked_count
|
|
||||||
+ skipped_after_queue,
|
|
||||||
paused_count=paused_count,
|
paused_count=paused_count,
|
||||||
batch_state=batch_state,
|
batch_state=batch_state,
|
||||||
batch_pause_reason_code=pause_reason_code,
|
batch_pause_reason_code=pause_reason_code,
|
||||||
@@ -1518,6 +1582,7 @@ def queue_failed_jobs_for_retry(
|
|||||||
include_permanent: bool = False,
|
include_permanent: bool = False,
|
||||||
force_max_attempts: bool = False,
|
force_max_attempts: bool = False,
|
||||||
enqueue_celery: bool = True,
|
enqueue_celery: bool = True,
|
||||||
|
run_inline: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Queue known failures and incomplete multi-channel deliveries.
|
"""Queue known failures and incomplete multi-channel deliveries.
|
||||||
@@ -1549,7 +1614,12 @@ def queue_failed_jobs_for_retry(
|
|||||||
version=version,
|
version=version,
|
||||||
job_ids=job_ids,
|
job_ids=job_ids,
|
||||||
):
|
):
|
||||||
if job.send_status not in allowed:
|
if (
|
||||||
|
job.send_status not in allowed
|
||||||
|
or job.claim_token is not None
|
||||||
|
or job.build_status != JobBuildStatus.BUILT.value
|
||||||
|
or not _single_job_validation_allowed(version, job, include_warnings=True)
|
||||||
|
):
|
||||||
skipped.append(
|
skipped.append(
|
||||||
{
|
{
|
||||||
"job_id": job.id,
|
"job_id": job.id,
|
||||||
@@ -1575,42 +1645,11 @@ def queue_failed_jobs_for_retry(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
selected.append(job)
|
selected.append(job)
|
||||||
if not dry_run:
|
return _execute_explicit_delivery_selection(
|
||||||
job.queue_status = JobQueueStatus.QUEUED.value
|
session, campaign=campaign, version=version, selected=selected,
|
||||||
job.send_status = JobSendStatus.QUEUED.value
|
skipped=skipped, action="retry_failed", enqueue_celery=enqueue_celery,
|
||||||
job.queued_at = _utcnow()
|
run_inline=run_inline, dry_run=dry_run,
|
||||||
job.claimed_at = None
|
)
|
||||||
job.claim_token = None
|
|
||||||
job.smtp_started_at = None
|
|
||||||
job.outcome_unknown_at = None
|
|
||||||
session.add(job)
|
|
||||||
|
|
||||||
if not dry_run:
|
|
||||||
if selected:
|
|
||||||
campaign.status = CampaignStatus.QUEUED.value
|
|
||||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
|
||||||
_set_version_delivery_mode(
|
|
||||||
version,
|
|
||||||
_asynchronous_delivery_mode(enqueue_celery),
|
|
||||||
)
|
|
||||||
session.add(campaign)
|
|
||||||
session.add(version)
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
enqueued = 0
|
|
||||||
if _should_enqueue_celery(enqueue_celery) and not dry_run:
|
|
||||||
for job in selected:
|
|
||||||
_celery_enqueue_send_job(job.id)
|
|
||||||
enqueued += 1
|
|
||||||
return {
|
|
||||||
"campaign_id": campaign.id,
|
|
||||||
"version_id": version.id,
|
|
||||||
"action": "retry_failed",
|
|
||||||
"selected_count": len(selected),
|
|
||||||
"enqueued_count": enqueued,
|
|
||||||
"skipped": skipped,
|
|
||||||
"dry_run": dry_run,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def queue_unattempted_jobs(
|
def queue_unattempted_jobs(
|
||||||
@@ -1621,6 +1660,7 @@ def queue_unattempted_jobs(
|
|||||||
version_id: str | None = None,
|
version_id: str | None = None,
|
||||||
job_ids: list[str] | None = None,
|
job_ids: list[str] | None = None,
|
||||||
enqueue_celery: bool = True,
|
enqueue_celery: bool = True,
|
||||||
|
run_inline: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Explicitly queue built jobs that have never started an SMTP attempt."""
|
"""Explicitly queue built jobs that have never started an SMTP attempt."""
|
||||||
@@ -1645,10 +1685,12 @@ def queue_unattempted_jobs(
|
|||||||
eligible = (
|
eligible = (
|
||||||
job.attempt_count == 0
|
job.attempt_count == 0
|
||||||
and job.postbox_attempt_count == 0
|
and job.postbox_attempt_count == 0
|
||||||
|
and job.print_attempt_count == 0
|
||||||
|
and job.claim_token is None
|
||||||
and job.send_status
|
and job.send_status
|
||||||
in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value}
|
in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value, JobSendStatus.QUEUED.value}
|
||||||
and job.build_status == JobBuildStatus.BUILT.value
|
and job.build_status == JobBuildStatus.BUILT.value
|
||||||
and job.validation_status in QUEUEABLE_VALIDATION_STATUSES
|
and _single_job_validation_allowed(version, job, include_warnings=True)
|
||||||
)
|
)
|
||||||
if not eligible:
|
if not eligible:
|
||||||
skipped.append(
|
skipped.append(
|
||||||
@@ -1659,41 +1701,86 @@ def queue_unattempted_jobs(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
selected.append(job)
|
selected.append(job)
|
||||||
if not dry_run:
|
return _execute_explicit_delivery_selection(
|
||||||
job.queue_status = JobQueueStatus.QUEUED.value
|
session, campaign=campaign, version=version, selected=selected,
|
||||||
job.send_status = JobSendStatus.QUEUED.value
|
skipped=skipped, action="send_unattempted", enqueue_celery=enqueue_celery,
|
||||||
job.queued_at = _utcnow()
|
run_inline=run_inline, dry_run=dry_run,
|
||||||
job.claimed_at = None
|
)
|
||||||
job.claim_token = None
|
|
||||||
job.smtp_started_at = None
|
|
||||||
job.outcome_unknown_at = None
|
def _execute_explicit_delivery_selection(
|
||||||
job.last_error = None
|
session: Session, *, campaign: Campaign, version: CampaignVersion,
|
||||||
session.add(job)
|
selected: list[CampaignJob], skipped: list[dict[str, str]], action: str,
|
||||||
|
enqueue_celery: bool, run_inline: bool, dry_run: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Queue a compare-and-set selection; inline recovery uses normal delivery."""
|
||||||
|
synchronous_policy = None
|
||||||
|
remaining_count = 0
|
||||||
|
if run_inline:
|
||||||
|
try:
|
||||||
|
synchronous_policy = effective_synchronous_send_policy(session, tenant_id=campaign.tenant_id)
|
||||||
|
except CampaignDeliveryPolicyError as exc:
|
||||||
|
raise QueueingError(f"Invalid synchronous Campaign delivery policy: {exc}") from exc
|
||||||
|
if synchronous_policy.max_recipient_jobs == 0:
|
||||||
|
raise QueueingError("Synchronous Campaign delivery is disabled by policy.")
|
||||||
|
remaining_count = max(0, len(selected) - synchronous_policy.max_recipient_jobs)
|
||||||
|
selected = selected[:synchronous_policy.max_recipient_jobs]
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
if selected:
|
_ensure_campaign_approval_gate(session, tenant_id=campaign.tenant_id, version=version)
|
||||||
campaign.status = CampaignStatus.QUEUED.value
|
claimed_selection = []
|
||||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
|
||||||
_set_version_delivery_mode(
|
|
||||||
version,
|
|
||||||
_asynchronous_delivery_mode(enqueue_celery),
|
|
||||||
)
|
|
||||||
session.add(campaign)
|
|
||||||
session.add(version)
|
|
||||||
session.commit()
|
|
||||||
enqueued = 0
|
|
||||||
if _should_enqueue_celery(enqueue_celery) and not dry_run:
|
|
||||||
for job in selected:
|
for job in selected:
|
||||||
_celery_enqueue_send_job(job.id)
|
# A worker or another operator may have claimed the row after the
|
||||||
enqueued += 1
|
# selection query. Never reset that claim or an accepted attempt.
|
||||||
return {
|
changed = session.query(CampaignJob).filter(
|
||||||
"campaign_id": campaign.id,
|
CampaignJob.id == job.id,
|
||||||
"version_id": version.id,
|
CampaignJob.tenant_id == campaign.tenant_id,
|
||||||
"action": "send_unattempted",
|
CampaignJob.send_status == job.send_status,
|
||||||
"selected_count": len(selected),
|
CampaignJob.queue_status == job.queue_status,
|
||||||
"enqueued_count": enqueued,
|
CampaignJob.attempt_count == job.attempt_count,
|
||||||
"skipped": skipped,
|
CampaignJob.postbox_attempt_count == job.postbox_attempt_count,
|
||||||
"dry_run": dry_run,
|
CampaignJob.print_attempt_count == job.print_attempt_count,
|
||||||
|
CampaignJob.claim_token.is_(None),
|
||||||
|
).update({
|
||||||
|
CampaignJob.queue_status: JobQueueStatus.QUEUED.value,
|
||||||
|
CampaignJob.send_status: JobSendStatus.QUEUED.value,
|
||||||
|
CampaignJob.queued_at: _utcnow(),
|
||||||
|
CampaignJob.claimed_at: None,
|
||||||
|
CampaignJob.smtp_started_at: None,
|
||||||
|
CampaignJob.outcome_unknown_at: None,
|
||||||
|
}, synchronize_session=False)
|
||||||
|
if changed:
|
||||||
|
session.refresh(job)
|
||||||
|
claimed_selection.append(job)
|
||||||
|
else:
|
||||||
|
skipped.append({"job_id": job.id, "reason": "delivery state changed during selection"})
|
||||||
|
selected = claimed_selection
|
||||||
|
_persist_campaign_queue(
|
||||||
|
session, campaign=campaign, version=version, queued=selected,
|
||||||
|
delivery_mode=DELIVERY_MODE_SYNCHRONOUS if run_inline else _asynchronous_delivery_mode(enqueue_celery),
|
||||||
|
commit=not run_inline,
|
||||||
|
)
|
||||||
|
result = {
|
||||||
|
"campaign_id": campaign.id, "version_id": version.id, "action": action,
|
||||||
|
"selected_count": len(selected), "remaining_count": remaining_count,
|
||||||
|
"enqueued_count": 0, "skipped": skipped, "dry_run": dry_run,
|
||||||
|
"run_inline": run_inline,
|
||||||
}
|
}
|
||||||
|
if run_inline and not dry_run and selected:
|
||||||
|
assert synchronous_policy is not None
|
||||||
|
try:
|
||||||
|
outcome = _send_synchronous_job_batch(
|
||||||
|
session, campaign=campaign, version=version, jobs=selected,
|
||||||
|
synchronous_policy=synchronous_policy, skipped_count=len(skipped),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Local preflight may fail before the batch connection is entered.
|
||||||
|
# No pending queue edits may leak into a caller's later commit.
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
result.update(outcome.as_dict())
|
||||||
|
elif not dry_run and _should_enqueue_celery(enqueue_celery) and not run_inline:
|
||||||
|
result["enqueued_count"] = _enqueue_campaign_jobs(selected, enabled=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def send_single_campaign_job(
|
def send_single_campaign_job(
|
||||||
@@ -2731,6 +2818,19 @@ def reconcile_job_outcome(
|
|||||||
snapshot = ensure_execution_snapshot(session, version)
|
snapshot = ensure_execution_snapshot(session, version)
|
||||||
now = _utcnow()
|
now = _utcnow()
|
||||||
attempt = _unfinished_attempt(session, job)
|
attempt = _unfinished_attempt(session, job)
|
||||||
|
if attempt is None:
|
||||||
|
attempt = session.query(SendAttempt).filter(
|
||||||
|
SendAttempt.job_id == job.id,
|
||||||
|
SendAttempt.status == JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||||
|
).order_by(SendAttempt.attempt_number.desc()).first()
|
||||||
|
if decision not in {"smtp_accepted", "not_sent"}:
|
||||||
|
raise QueueingError("decision must be 'smtp_accepted' or 'not_sent'")
|
||||||
|
from govoplan_campaign.backend.services.delivery_recovery import reconcile_campaign_delivery_operation
|
||||||
|
reconcile_campaign_delivery_operation(
|
||||||
|
session, job=job, channel="smtp", claim_token=getattr(attempt, "claim_token", None),
|
||||||
|
effect_occurred=decision == "smtp_accepted", note=evidence_note,
|
||||||
|
)
|
||||||
|
_claim_unknown_reconciliation(session, job, channel="smtp", next_status="smtp_accepted" if decision == "smtp_accepted" else "failed_temporary")
|
||||||
if decision == "smtp_accepted":
|
if decision == "smtp_accepted":
|
||||||
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
|
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
|
||||||
job.queue_status = JobQueueStatus.DRAFT.value
|
job.queue_status = JobQueueStatus.DRAFT.value
|
||||||
@@ -2926,6 +3026,8 @@ def _reconcile_imap_append_outcome(
|
|||||||
raise QueueingError(
|
raise QueueingError(
|
||||||
f"IMAP status {job.imap_status} does not require reconciliation"
|
f"IMAP status {job.imap_status} does not require reconciliation"
|
||||||
)
|
)
|
||||||
|
if decision not in {"imap_appended", "imap_not_appended"}:
|
||||||
|
raise QueueingError("IMAP decision must be 'imap_appended' or 'imap_not_appended'")
|
||||||
|
|
||||||
attempt = (
|
attempt = (
|
||||||
session.query(ImapAppendAttempt)
|
session.query(ImapAppendAttempt)
|
||||||
@@ -2933,6 +3035,12 @@ def _reconcile_imap_append_outcome(
|
|||||||
.order_by(ImapAppendAttempt.attempt_number.desc())
|
.order_by(ImapAppendAttempt.attempt_number.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.services.delivery_recovery import reconcile_campaign_delivery_operation
|
||||||
|
reconcile_campaign_delivery_operation(
|
||||||
|
session, job=job, channel="imap", claim_token=getattr(attempt, "claim_token", None),
|
||||||
|
effect_occurred=decision == "imap_appended", note=evidence_note,
|
||||||
|
)
|
||||||
|
_claim_unknown_reconciliation(session, job, channel="imap", next_status="appended" if decision == "imap_appended" else "failed")
|
||||||
if decision == "imap_appended":
|
if decision == "imap_appended":
|
||||||
job.imap_status = JobImapStatus.APPENDED.value
|
job.imap_status = JobImapStatus.APPENDED.value
|
||||||
attempt_status = "reconciled_imap_appended"
|
attempt_status = "reconciled_imap_appended"
|
||||||
@@ -2971,6 +3079,22 @@ def _reconcile_imap_append_outcome(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_unknown_reconciliation(session: Session, job: CampaignJob, *, channel: str, next_status: str) -> None:
|
||||||
|
"""Conflicting operators cannot overwrite an already reconciled outcome."""
|
||||||
|
column = CampaignJob.send_status if channel == "smtp" else CampaignJob.imap_status
|
||||||
|
claim_column = CampaignJob.claim_token if channel == "smtp" else CampaignJob.imap_claim_token
|
||||||
|
claim_token = getattr(job, "claim_token" if channel == "smtp" else "imap_claim_token", None)
|
||||||
|
changed = session.query(CampaignJob).filter(
|
||||||
|
CampaignJob.id == job.id,
|
||||||
|
CampaignJob.tenant_id == job.tenant_id,
|
||||||
|
column == "outcome_unknown",
|
||||||
|
claim_column == claim_token,
|
||||||
|
).update({column: next_status}, synchronize_session=False)
|
||||||
|
if changed != 1:
|
||||||
|
raise QueueingError("The delivery outcome changed; reload its current evidence before reconciliation.")
|
||||||
|
session.refresh(job)
|
||||||
|
|
||||||
|
|
||||||
def _verify_eml_evidence(job: CampaignJob, payload: bytes) -> None:
|
def _verify_eml_evidence(job: CampaignJob, payload: bytes) -> None:
|
||||||
if job.eml_size_bytes is not None and len(payload) != job.eml_size_bytes:
|
if job.eml_size_bytes is not None and len(payload) != job.eml_size_bytes:
|
||||||
raise SendJobError(
|
raise SendJobError(
|
||||||
@@ -3615,10 +3739,10 @@ def _preflight_send_campaign_job(
|
|||||||
message="A delivery outcome is unresolved; reconcile it before any retry.",
|
message="A delivery outcome is unresolved; reconcile it before any retry.",
|
||||||
)
|
)
|
||||||
if job.send_status == JobSendStatus.SENDING.value:
|
if job.send_status == JobSendStatus.SENDING.value:
|
||||||
return mark_job_outcome_unknown(
|
return SendJobResult(
|
||||||
session,
|
job_id=job.id, status="already_sending", attempt_number=job.attempt_count,
|
||||||
job,
|
dry_run=dry_run,
|
||||||
reason="A delivery task resumed while the previous channel attempt was still marked in progress. Automatic redelivery was stopped.",
|
message="Another runtime owns the active delivery. A stopped runtime's claim requires explicit guarded recovery.",
|
||||||
)
|
)
|
||||||
if job.send_status == JobSendStatus.CLAIMED.value:
|
if job.send_status == JobSendStatus.CLAIMED.value:
|
||||||
return SendJobResult(
|
return SendJobResult(
|
||||||
@@ -4983,13 +5107,19 @@ def _perform_imap_append(
|
|||||||
)
|
)
|
||||||
raise ImapAppendError(reason, outcome_unknown=True) from None
|
raise ImapAppendError(reason, outcome_unknown=True) from None
|
||||||
try:
|
try:
|
||||||
return _record_imap_append_success(
|
outcome = _record_imap_append_success(
|
||||||
session,
|
session,
|
||||||
job=claimed.job,
|
job=claimed.job,
|
||||||
attempt=claimed.attempt,
|
attempt=claimed.attempt,
|
||||||
claim_token=claimed.claim_token,
|
claim_token=claimed.claim_token,
|
||||||
folder=result.folder,
|
folder=result.folder,
|
||||||
)
|
)
|
||||||
|
return replace(
|
||||||
|
outcome,
|
||||||
|
connection_sequence=getattr(result, "connection_sequence", None),
|
||||||
|
session_reused=bool(getattr(result, "session_reused", False)),
|
||||||
|
reconnect_count=int(getattr(result, "reconnect_count", 0) or 0),
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return _mark_imap_append_outcome_unknown_after_effect(
|
return _mark_imap_append_outcome_unknown_after_effect(
|
||||||
session,
|
session,
|
||||||
@@ -5194,6 +5324,7 @@ def enqueue_pending_imap_appends(
|
|||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
|
version_id: str | None = None,
|
||||||
enqueue_celery: bool = True,
|
enqueue_celery: bool = True,
|
||||||
run_inline: bool = False,
|
run_inline: bool = False,
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
@@ -5201,11 +5332,13 @@ def enqueue_pending_imap_appends(
|
|||||||
campaign = _get_campaign_for_tenant(
|
campaign = _get_campaign_for_tenant(
|
||||||
session, campaign_id=campaign_id, tenant_id=tenant_id
|
session, campaign_id=campaign_id, tenant_id=tenant_id
|
||||||
)
|
)
|
||||||
|
version = _get_version_for_campaign(session, campaign, version_id=version_id)
|
||||||
jobs = (
|
jobs = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
.filter(
|
.filter(
|
||||||
CampaignJob.tenant_id == tenant_id,
|
CampaignJob.tenant_id == tenant_id,
|
||||||
CampaignJob.campaign_id == campaign.id,
|
CampaignJob.campaign_id == campaign.id,
|
||||||
|
CampaignJob.campaign_version_id == version.id,
|
||||||
CampaignJob.imap_status.in_(
|
CampaignJob.imap_status.in_(
|
||||||
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
||||||
),
|
),
|
||||||
@@ -5228,44 +5361,55 @@ def enqueue_pending_imap_appends(
|
|||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
appended_count = 0
|
appended_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
|
outcome_unknown_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
|
connection_count = 0
|
||||||
|
reconnect_count = 0
|
||||||
if run_inline or dry_run:
|
if run_inline or dry_run:
|
||||||
for job in jobs:
|
batch_context = nullcontext() if dry_run else mail_integration().campaign_imap_batch(tenant_id=tenant_id, campaign_id=campaign.id)
|
||||||
try:
|
with batch_context as batch:
|
||||||
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
|
for job in jobs:
|
||||||
payload = result.as_dict()
|
try:
|
||||||
results.append(payload)
|
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
|
||||||
if result.status == JobImapStatus.APPENDED.value:
|
payload = result.as_dict()
|
||||||
appended_count += 1
|
results.append(payload)
|
||||||
elif result.status in {
|
if result.status == JobImapStatus.APPENDED.value:
|
||||||
"skipped",
|
appended_count += 1
|
||||||
"not_requested",
|
elif result.status == JobImapStatus.OUTCOME_UNKNOWN.value:
|
||||||
"not_sent",
|
outcome_unknown_count += 1
|
||||||
"already_appended",
|
elif result.status == JobImapStatus.FAILED.value:
|
||||||
"dry_run",
|
failed_count += 1
|
||||||
}:
|
else:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
except (
|
except Exception as exc:
|
||||||
Exception
|
# An uncertain append remains frozen by its per-job
|
||||||
) as exc: # keep processing later jobs and expose per-job details
|
# pipeline. The batch never retries the same message.
|
||||||
failed_count += 1
|
uncertain = bool(getattr(exc, "outcome_unknown", False))
|
||||||
results.append(
|
if uncertain:
|
||||||
{"job_id": job.id, "status": "failed", "message": str(exc)}
|
outcome_unknown_count += 1
|
||||||
)
|
else:
|
||||||
|
failed_count += 1
|
||||||
|
results.append({"job_id": job.id, "status": "outcome_unknown" if uncertain else "failed", "message": str(exc)})
|
||||||
|
connection_count = int(getattr(batch, "connection_count", 0) or 0)
|
||||||
|
reconnect_count = int(getattr(batch, "reconnect_count", 0) or 0)
|
||||||
elif should_enqueue:
|
elif should_enqueue:
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
_celery_enqueue_append_sent_job(job.id)
|
_celery_enqueue_append_sent_job(job.id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"campaign_id": campaign.id,
|
"campaign_id": campaign.id,
|
||||||
|
"version_id": version.id,
|
||||||
"pending_count": len(jobs),
|
"pending_count": len(jobs),
|
||||||
"enqueued_count": len(jobs) if should_enqueue else 0,
|
"enqueued_count": len(jobs) if should_enqueue else 0,
|
||||||
"processed_count": len(results) if run_inline and not dry_run else 0,
|
"processed_count": len(results) if run_inline and not dry_run else 0,
|
||||||
"appended_count": appended_count,
|
"appended_count": appended_count,
|
||||||
"failed_count": failed_count,
|
"failed_count": failed_count,
|
||||||
|
"outcome_unknown_count": outcome_unknown_count,
|
||||||
"skipped_count": skipped_count,
|
"skipped_count": skipped_count,
|
||||||
"dry_run": dry_run,
|
"dry_run": dry_run,
|
||||||
"run_inline": run_inline,
|
"run_inline": run_inline,
|
||||||
|
"imap_connection_count": connection_count,
|
||||||
|
"imap_reconnect_count": reconnect_count,
|
||||||
"results": results,
|
"results": results,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Small, address-free delivery counters for an explicitly selected version."""
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
|
||||||
|
from govoplan_campaign.backend.sending.jobs import _get_campaign_for_tenant, _get_version_for_campaign
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_delivery_progress(session: Session, *, tenant_id: str, campaign_id: str, version_id: str | None = None) -> dict[str, Any]:
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
|
version = _get_version_for_campaign(session, campaign, version_id=version_id)
|
||||||
|
# Aggregate status columns only. Never load recipient/attachment JSON, EML,
|
||||||
|
# issues, or attempts for a progress poll.
|
||||||
|
accepted_attempt = session.query(SendAttempt.id).filter(
|
||||||
|
SendAttempt.job_id == CampaignJob.id,
|
||||||
|
SendAttempt.status.in_(("smtp_accepted", "smtp_accepted_with_refusals", "reconciled_smtp_accepted")),
|
||||||
|
).exists()
|
||||||
|
mail_attempted = session.query(SendAttempt.id).filter(SendAttempt.job_id == CampaignJob.id).exists()
|
||||||
|
rows = session.query(
|
||||||
|
CampaignJob.send_status, CampaignJob.queue_status,
|
||||||
|
CampaignJob.imap_status, CampaignJob.delivery_channel_policy,
|
||||||
|
CampaignJob.validation_status, accepted_attempt, mail_attempted, func.count(CampaignJob.id),
|
||||||
|
).filter(
|
||||||
|
CampaignJob.tenant_id == tenant_id,
|
||||||
|
CampaignJob.campaign_id == campaign.id,
|
||||||
|
CampaignJob.campaign_version_id == version.id,
|
||||||
|
).group_by(
|
||||||
|
CampaignJob.send_status, CampaignJob.queue_status,
|
||||||
|
CampaignJob.imap_status, CampaignJob.delivery_channel_policy,
|
||||||
|
CampaignJob.validation_status, accepted_attempt, mail_attempted,
|
||||||
|
).all()
|
||||||
|
smtp = dict.fromkeys(("total", "processed", "accepted", "active", "pending", "failed", "outcome_unknown", "excluded", "paused", "cancelled"), 0)
|
||||||
|
imap = dict.fromkeys(("total", "processed", "appended", "active", "pending", "failed", "outcome_unknown", "excluded"), 0)
|
||||||
|
raw = {"send": Counter(), "queue": Counter(), "imap": Counter()}
|
||||||
|
total = 0
|
||||||
|
for send, queue, append, channel, validation, mail_accepted, attempted_mail, count in rows:
|
||||||
|
total += count
|
||||||
|
raw["send"][send] += count
|
||||||
|
raw["queue"][queue] += count
|
||||||
|
raw["imap"][append] += count
|
||||||
|
# Multi-channel jobs retain their channel policy. Pure Postbox/Print
|
||||||
|
# jobs and policy-excluded mail are not SMTP work.
|
||||||
|
fallback_completed = channel in {"mail_then_postbox", "postbox_then_mail", "mail_then_print"} and send in {"delivered", "sent", "postbox_accepted", "print_accepted"}
|
||||||
|
if channel not in {"mail", "mail_and_postbox", "mail_then_postbox", "postbox_then_mail", "mail_then_print"} or send == "skipped" or validation in {"excluded", "inactive"} or (fallback_completed and not attempted_mail and not mail_accepted):
|
||||||
|
smtp["excluded"] += count
|
||||||
|
else:
|
||||||
|
smtp["total"] += count
|
||||||
|
if mail_accepted or send == "smtp_accepted" or (channel == "mail" and send in {"sent", "delivered"}):
|
||||||
|
bucket = "accepted"
|
||||||
|
elif send in {"claimed", "sending"}:
|
||||||
|
bucket = "active"
|
||||||
|
elif send == "outcome_unknown" or (channel != "mail" and send in {"sent", "delivered"}):
|
||||||
|
bucket = "outcome_unknown"
|
||||||
|
elif send in {"failed_temporary", "failed_permanent", "partially_accepted", "postbox_accepted", "print_accepted"}:
|
||||||
|
bucket = "failed"
|
||||||
|
elif send == "cancelled" or queue == "cancelled":
|
||||||
|
bucket = "cancelled"
|
||||||
|
elif queue == "paused":
|
||||||
|
bucket = "paused"
|
||||||
|
else:
|
||||||
|
bucket = "pending"
|
||||||
|
smtp[bucket] += count
|
||||||
|
if append in {"not_requested", "skipped"}:
|
||||||
|
imap["excluded"] += count
|
||||||
|
else:
|
||||||
|
imap["total"] += count
|
||||||
|
bucket = {"appended": "appended", "appending": "active", "failed": "failed", "outcome_unknown": "outcome_unknown"}.get(append, "pending")
|
||||||
|
imap[bucket] += count
|
||||||
|
smtp["processed"] = sum(smtp[key] for key in ("accepted", "failed", "outcome_unknown", "cancelled"))
|
||||||
|
imap["processed"] = sum(imap[key] for key in ("appended", "failed", "outcome_unknown"))
|
||||||
|
return {
|
||||||
|
"campaign_id": campaign.id, "version_id": version.id,
|
||||||
|
"total_jobs": total, "generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"smtp": smtp, "imap": imap,
|
||||||
|
"status_counts": {kind: dict(counts) for kind, counts in raw.items()},
|
||||||
|
"workflow_state": version.workflow_state,
|
||||||
|
"delivery_mode": version.delivery_mode,
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Explicit, fenced recovery of claims left by a proven stopped runtime."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.core.runtime_coordination import (
|
||||||
|
DistributedLease, RuntimeNode, acquire_lease, release_lease, process_runtime_identity,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.recovery import (
|
||||||
|
RecoveryOperation, RecoveryStatus, record_recovery_checkpoint,
|
||||||
|
transition_recovery_operation, verify_recovery_evidence_chain,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt, ImapAppendAttempt
|
||||||
|
from govoplan_campaign.backend.sending.jobs import QueueingError, _update_campaign_after_job
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryStateConflict(QueueingError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _utc(value: datetime) -> datetime:
|
||||||
|
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _key(job: CampaignJob, channel: str) -> str:
|
||||||
|
return f"campaign:{'delivery' if channel == 'smtp' else 'imap'}:{job.tenant_id}:{job.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_metadata(job: CampaignJob, channel: str, lease: DistributedLease | None, node: RuntimeNode | None) -> dict[str, Any]:
|
||||||
|
state = job.send_status if channel == "smtp" else job.imap_status
|
||||||
|
claim = job.claim_token if channel == "smtp" else job.imap_claim_token
|
||||||
|
active = state in ({"claimed", "sending"} if channel == "smtp" else {"appending"})
|
||||||
|
reason = "not_active"
|
||||||
|
if active:
|
||||||
|
if lease is None or not claim or not lease.holder_node_id or node is None:
|
||||||
|
reason = "owner_not_confirmed_stopped"
|
||||||
|
elif _utc(lease.expires_at) > datetime.now(timezone.utc):
|
||||||
|
reason = "live_claim"
|
||||||
|
elif node.incarnation == lease.holder_incarnation and node.state != "stopped":
|
||||||
|
# Heartbeat age alone is NOT proof that a slow worker is dead.
|
||||||
|
reason = "owner_not_confirmed_stopped"
|
||||||
|
else:
|
||||||
|
reason = "recoverable"
|
||||||
|
revision = hashlib.sha256(json.dumps({
|
||||||
|
"job": job.id, "channel": channel, "state": state, "claim": claim,
|
||||||
|
"attempts": job.attempt_count,
|
||||||
|
"lease": [lease.id, lease.fencing_token, str(lease.expires_at), lease.holder_node_id, lease.holder_incarnation] if lease else None,
|
||||||
|
"owner": [node.incarnation, node.state] if node else None,
|
||||||
|
}, sort_keys=True).encode()).hexdigest()
|
||||||
|
return {"eligible": reason == "recoverable", "revision": revision, "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
def job_recovery_metadata(session: Session, jobs: list[CampaignJob]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Two bounded metadata reads per loaded page, not per recipient."""
|
||||||
|
if not jobs:
|
||||||
|
return {}
|
||||||
|
installation_id = process_runtime_identity().installation_id
|
||||||
|
keys = [_key(job, channel) for job in jobs for channel in ("smtp", "imap")]
|
||||||
|
leases = session.query(DistributedLease).filter(
|
||||||
|
DistributedLease.installation_id == installation_id,
|
||||||
|
DistributedLease.resource_key.in_(keys),
|
||||||
|
).all()
|
||||||
|
by_key = {lease.resource_key: lease for lease in leases}
|
||||||
|
owner_ids = {lease.holder_node_id for lease in leases if lease.holder_node_id}
|
||||||
|
nodes = session.query(RuntimeNode).filter(
|
||||||
|
RuntimeNode.installation_id == installation_id,
|
||||||
|
RuntimeNode.node_id.in_(owner_ids),
|
||||||
|
).all() if owner_ids else []
|
||||||
|
by_owner = {node.node_id: node for node in nodes}
|
||||||
|
result = {}
|
||||||
|
for job in jobs:
|
||||||
|
channels = {}
|
||||||
|
for channel in ("smtp", "imap"):
|
||||||
|
lease = by_key.get(_key(job, channel))
|
||||||
|
channels[channel] = _claim_metadata(job, channel, lease, by_owner.get(lease.holder_node_id) if lease else None)
|
||||||
|
result[job.id] = channels
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def recover_stale_delivery_claim(
|
||||||
|
session: Session, *, tenant_id: str, campaign_id: str, job_id: str,
|
||||||
|
channel: str, expected_revision: str, note: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Freeze an abandoned effect as unknown; NEVER infer that it was not sent."""
|
||||||
|
if channel not in {"smtp", "imap"} or not note.strip():
|
||||||
|
raise QueueingError("Claim recovery requires a channel and an evidence note.")
|
||||||
|
job = session.get(CampaignJob, job_id)
|
||||||
|
if job is None or job.tenant_id != tenant_id or job.campaign_id != campaign_id:
|
||||||
|
raise QueueingError("Campaign job not found or not accessible")
|
||||||
|
identity = process_runtime_identity()
|
||||||
|
resource_key = _key(job, channel)
|
||||||
|
# Same lock order as runtime authority: lease, operation, domain row.
|
||||||
|
lease = session.query(DistributedLease).filter(
|
||||||
|
DistributedLease.installation_id == identity.installation_id,
|
||||||
|
DistributedLease.resource_key == resource_key,
|
||||||
|
).with_for_update().populate_existing().one_or_none()
|
||||||
|
node = session.query(RuntimeNode).filter(
|
||||||
|
RuntimeNode.installation_id == identity.installation_id,
|
||||||
|
RuntimeNode.node_id == lease.holder_node_id,
|
||||||
|
).with_for_update().populate_existing().one_or_none() if lease and lease.holder_node_id else None
|
||||||
|
session.refresh(job)
|
||||||
|
metadata = _claim_metadata(job, channel, lease, node)
|
||||||
|
if metadata["revision"] != expected_revision:
|
||||||
|
raise RecoveryStateConflict("Delivery claim changed; reload its current evidence before recovery.")
|
||||||
|
if not metadata["eligible"]:
|
||||||
|
raise RecoveryStateConflict("Recovery is blocked until the lease expires and its owning runtime is confirmed stopped or replaced.")
|
||||||
|
claim_token = job.claim_token if channel == "smtp" else job.imap_claim_token
|
||||||
|
state = job.send_status if channel == "smtp" else job.imap_status
|
||||||
|
assert claim_token is not None
|
||||||
|
claim_sha = hashlib.sha256(claim_token.encode()).hexdigest()
|
||||||
|
operation_key = f"campaign-{'delivery' if channel == 'smtp' else 'imap'}:{job.id}:{claim_sha[:32]}"
|
||||||
|
operation = session.query(RecoveryOperation).filter(
|
||||||
|
RecoveryOperation.installation_id == identity.installation_id,
|
||||||
|
RecoveryOperation.module_id == "campaigns",
|
||||||
|
RecoveryOperation.idempotency_key == operation_key,
|
||||||
|
RecoveryOperation.lease_resource_key == resource_key,
|
||||||
|
).with_for_update().one_or_none()
|
||||||
|
if operation is None or operation.status not in {"running", "outcome_unknown"}:
|
||||||
|
raise RecoveryStateConflict("The original durable delivery evidence cannot be recovered safely.")
|
||||||
|
authority = acquire_lease(
|
||||||
|
session, installation_id=identity.installation_id, resource_key=resource_key,
|
||||||
|
holder_node_id=identity.node_id, holder_incarnation=identity.incarnation,
|
||||||
|
ttl_seconds=300, metadata={"module_id": "campaigns", "recovery_operation_id": operation.id},
|
||||||
|
)
|
||||||
|
if authority is None:
|
||||||
|
raise RecoveryStateConflict("Another runtime acquired this delivery claim.")
|
||||||
|
operation.holder_node_id = authority.holder_node_id
|
||||||
|
operation.holder_incarnation = authority.holder_incarnation
|
||||||
|
operation.fencing_token = authority.fencing_token
|
||||||
|
session.add(operation)
|
||||||
|
session.flush()
|
||||||
|
evidence = {"job_id": job.id, "channel": channel, "previous_state": state, "evidence_note_sha256": hashlib.sha256(note.strip().encode()).hexdigest(), "claim_sha256": claim_sha}
|
||||||
|
record_recovery_checkpoint(session, operation, kind="campaign-claim-recovery", summary="An operator fenced a claim owned by a stopped runtime", evidence=evidence, lease_claim=authority)
|
||||||
|
if operation.status != "outcome_unknown":
|
||||||
|
transition_recovery_operation(session, operation, status=RecoveryStatus.OUTCOME_UNKNOWN, kind="campaign-claim-outcome-unknown", summary="The abandoned provider effect requires explicit reconciliation", evidence=evidence, failure_summary="The original runtime stopped before recording a final provider result", lease_claim=authority)
|
||||||
|
if not verify_recovery_evidence_chain(session, operation.id):
|
||||||
|
raise RecoveryStateConflict("Durable delivery evidence verification failed.")
|
||||||
|
state_column = CampaignJob.send_status if channel == "smtp" else CampaignJob.imap_status
|
||||||
|
claim_column = CampaignJob.claim_token if channel == "smtp" else CampaignJob.imap_claim_token
|
||||||
|
changes = {state_column: "outcome_unknown", claim_column: None, CampaignJob.last_error: note.strip()}
|
||||||
|
if channel == "smtp":
|
||||||
|
changes.update({CampaignJob.queue_status: "draft", CampaignJob.outcome_unknown_at: datetime.now(timezone.utc)})
|
||||||
|
else:
|
||||||
|
changes[CampaignJob.imap_claimed_at] = None
|
||||||
|
changed = session.query(CampaignJob).filter(
|
||||||
|
CampaignJob.id == job.id, state_column == state, claim_column == claim_token,
|
||||||
|
).update(changes, synchronize_session=False)
|
||||||
|
if changed != 1:
|
||||||
|
raise RecoveryStateConflict("The delivery claim changed before recovery could be recorded.")
|
||||||
|
attempt_model = SendAttempt if channel == "smtp" else ImapAppendAttempt
|
||||||
|
attempt = session.query(attempt_model).filter(attempt_model.job_id == job.id, attempt_model.claim_token == claim_token).order_by(attempt_model.attempt_number.desc()).first()
|
||||||
|
if attempt is not None:
|
||||||
|
attempt.status = "outcome_unknown"
|
||||||
|
attempt.error_message = note.strip()
|
||||||
|
if channel == "smtp":
|
||||||
|
attempt.finished_at = datetime.now(timezone.utc)
|
||||||
|
session.add(attempt)
|
||||||
|
release_lease(session, authority)
|
||||||
|
session.expire(job)
|
||||||
|
_update_campaign_after_job(session, campaign_id, job.campaign_version_id)
|
||||||
|
session.flush()
|
||||||
|
return {"campaign_id": campaign_id, "version_id": job.campaign_version_id, "job_id": job.id, "channel": channel, "send_status": job.send_status, "imap_status": job.imap_status, "note": note.strip(), "reconciliation_required": True}
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_campaign_delivery_operation(
|
||||||
|
session: Session, *, job: CampaignJob, channel: str, claim_token: str | None,
|
||||||
|
effect_occurred: bool, note: str,
|
||||||
|
) -> None:
|
||||||
|
"""Resolve only the original Campaign ledger in the caller's audit transaction.
|
||||||
|
|
||||||
|
Older jobs without a claim-bound operation remain supported. Mail's nested
|
||||||
|
provider-effect ledgers are separate evidence and are never rewritten here.
|
||||||
|
"""
|
||||||
|
# A compound external-channel operation may include Postbox/Print effects.
|
||||||
|
# One SMTP decision cannot verify or negate that entire operation.
|
||||||
|
if not claim_token or (channel == "smtp" and getattr(job, "delivery_channel_policy", "mail") != "mail"):
|
||||||
|
return
|
||||||
|
identity = process_runtime_identity()
|
||||||
|
key = _key(job, channel)
|
||||||
|
claim_sha = hashlib.sha256(claim_token.encode()).hexdigest()
|
||||||
|
operation_key = f"campaign-{'delivery' if channel == 'smtp' else 'imap'}:{job.id}:{claim_sha[:32]}"
|
||||||
|
# Acquire the same lock order as effect execution and claim recovery.
|
||||||
|
lease = session.query(DistributedLease).filter(
|
||||||
|
DistributedLease.installation_id == identity.installation_id,
|
||||||
|
DistributedLease.resource_key == key,
|
||||||
|
).with_for_update().populate_existing().one_or_none()
|
||||||
|
operation = session.query(RecoveryOperation).filter(
|
||||||
|
RecoveryOperation.installation_id == identity.installation_id,
|
||||||
|
RecoveryOperation.module_id == "campaigns",
|
||||||
|
RecoveryOperation.idempotency_key == operation_key,
|
||||||
|
RecoveryOperation.lease_resource_key == key,
|
||||||
|
RecoveryOperation.resource_type == "campaign_job",
|
||||||
|
RecoveryOperation.resource_id == job.id,
|
||||||
|
).with_for_update().populate_existing().one_or_none()
|
||||||
|
if operation is None:
|
||||||
|
return
|
||||||
|
if operation.status in {"succeeded", "recovered"}:
|
||||||
|
if (operation.status == "succeeded") != effect_occurred:
|
||||||
|
raise RecoveryStateConflict("The original durable operation already records a different verified outcome.")
|
||||||
|
return
|
||||||
|
if operation.status != "outcome_unknown" or lease is None:
|
||||||
|
raise RecoveryStateConflict("The original durable operation requires guarded claim recovery before reconciliation.")
|
||||||
|
# Even the same API process must not borrow another active operation's
|
||||||
|
# lease merely because its runtime identity happens to match.
|
||||||
|
if lease.holder_node_id is not None:
|
||||||
|
raise RecoveryStateConflict("The original operation still has a runtime owner; recover its stopped claim before reconciliation.")
|
||||||
|
authority = acquire_lease(session, installation_id=identity.installation_id, resource_key=key,
|
||||||
|
holder_node_id=identity.node_id, holder_incarnation=identity.incarnation,
|
||||||
|
ttl_seconds=300, metadata={"module_id": "campaigns", "recovery_operation_id": operation.id})
|
||||||
|
if authority is None:
|
||||||
|
raise RecoveryStateConflict("Another runtime owns the original delivery operation.")
|
||||||
|
operation.holder_node_id = authority.holder_node_id
|
||||||
|
operation.holder_incarnation = authority.holder_incarnation
|
||||||
|
operation.fencing_token = authority.fencing_token
|
||||||
|
session.add(operation)
|
||||||
|
session.flush()
|
||||||
|
evidence = {"verified": True, "checks": {"operator_provider_evidence_recorded": True, "matching_claim_attempt": True},
|
||||||
|
"job_id": job.id, "channel": channel, "effect_occurred": effect_occurred,
|
||||||
|
"claim_sha256": claim_sha, "evidence_note_sha256": hashlib.sha256(note.strip().encode()).hexdigest()}
|
||||||
|
record_recovery_checkpoint(session, operation, kind="campaign-reconciliation-fence", summary="An operator acquired authority for the original Campaign attempt", evidence=evidence, lease_claim=authority)
|
||||||
|
if effect_occurred:
|
||||||
|
transition_recovery_operation(session, operation, status=RecoveryStatus.SUCCEEDED,
|
||||||
|
kind="campaign-reconciled-provider-acceptance", summary="Operator evidence confirms the Campaign effect was accepted", evidence=evidence, lease_claim=authority)
|
||||||
|
else:
|
||||||
|
for next_status in (RecoveryStatus.RECOVERY_REQUIRED, RecoveryStatus.RECOVERING, RecoveryStatus.RECOVERED):
|
||||||
|
transition_recovery_operation(session, operation, status=next_status,
|
||||||
|
kind=f"campaign-reconciled-absence-{next_status.value}", summary="Operator evidence confirms the Campaign effect did not occur",
|
||||||
|
evidence=evidence, failure_summary="The original external effect was verified absent" if next_status == RecoveryStatus.RECOVERY_REQUIRED else None, lease_claim=authority)
|
||||||
|
if not verify_recovery_evidence_chain(session, operation.id):
|
||||||
|
raise RecoveryStateConflict("Original Campaign recovery evidence verification failed.")
|
||||||
|
release_lease(session, authority)
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.services.review_decisions import review_decision_metadata
|
||||||
|
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import HTTPException, Query, status
|
from fastapi import HTTPException, Query, status
|
||||||
from sqlalchemy import and_, func, or_
|
from sqlalchemy import String, and_, cast, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_campaign.backend.schemas import (
|
from govoplan_campaign.backend.schemas import (
|
||||||
@@ -68,11 +71,38 @@ def _job_review_key(job: CampaignJob) -> str:
|
|||||||
return str(job.entry_id or job.entry_index)
|
return str(job.entry_id or job.entry_index)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_recipient_groups(value: object) -> dict[str, list[dict[str, str]]]:
|
||||||
|
"""Project only frozen recipient display fields, in their authored order.
|
||||||
|
|
||||||
|
The recipient-aware jobs endpoints enforce recipient-read before loading
|
||||||
|
these rows. Do not project other arbitrary data from the frozen envelope.
|
||||||
|
"""
|
||||||
|
recipients = value if isinstance(value, dict) else {}
|
||||||
|
groups: dict[str, list[dict[str, str]]] = {}
|
||||||
|
for group in ("to", "cc", "bcc"):
|
||||||
|
values = recipients.get(group)
|
||||||
|
entries = values if isinstance(values, list) else [values]
|
||||||
|
addresses: list[dict[str, str]] = []
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict) or not isinstance(entry.get("email"), str):
|
||||||
|
continue
|
||||||
|
email = entry["email"].strip()
|
||||||
|
if not email:
|
||||||
|
continue
|
||||||
|
address = {"email": email}
|
||||||
|
if isinstance(entry.get("name"), str) and entry["name"].strip():
|
||||||
|
address["name"] = entry["name"].strip()
|
||||||
|
addresses.append(address)
|
||||||
|
groups[group] = addresses
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
def _job_summary_payload(
|
def _job_summary_payload(
|
||||||
job: CampaignJob,
|
job: CampaignJob,
|
||||||
*,
|
*,
|
||||||
reviewed_keys: set[str] | None = None,
|
reviewed_keys: set[str] | None = None,
|
||||||
calendar_invitation: dict[str, object] | None = None,
|
calendar_invitation: dict[str, object] | None = None,
|
||||||
|
recovery: dict[str, object] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
review_key = _job_review_key(job)
|
review_key = _job_review_key(job)
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +111,8 @@ def _job_summary_payload(
|
|||||||
"entry_index": job.entry_index,
|
"entry_index": job.entry_index,
|
||||||
"entry_id": job.entry_id,
|
"entry_id": job.entry_id,
|
||||||
"recipient_email": job.recipient_email,
|
"recipient_email": job.recipient_email,
|
||||||
|
"resolved_recipients": _public_recipient_groups(getattr(job, "resolved_recipients", None)),
|
||||||
|
"recovery": recovery or {},
|
||||||
"subject": job.subject,
|
"subject": job.subject,
|
||||||
"message_id_header": job.message_id_header,
|
"message_id_header": job.message_id_header,
|
||||||
"build_status": job.build_status,
|
"build_status": job.build_status,
|
||||||
@@ -114,6 +146,7 @@ def _job_summary_payload(
|
|||||||
"attachment_count": len(job.resolved_attachments or []),
|
"attachment_count": len(job.resolved_attachments or []),
|
||||||
"review_key": review_key,
|
"review_key": review_key,
|
||||||
"reviewed": review_key in reviewed_keys if reviewed_keys is not None else False,
|
"reviewed": review_key in reviewed_keys if reviewed_keys is not None else False,
|
||||||
|
"review_decision": review_decision_metadata(job),
|
||||||
"matched_file_count": sum(
|
"matched_file_count": sum(
|
||||||
len(item.get("matches") or [])
|
len(item.get("matches") or [])
|
||||||
for item in (job.resolved_attachments or [])
|
for item in (job.resolved_attachments or [])
|
||||||
@@ -128,9 +161,10 @@ def _job_detail_payload(
|
|||||||
job: CampaignJob,
|
job: CampaignJob,
|
||||||
*,
|
*,
|
||||||
calendar_invitation: dict[str, object] | None = None,
|
calendar_invitation: dict[str, object] | None = None,
|
||||||
|
recovery: dict[str, object] | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
**_job_summary_payload(job, calendar_invitation=calendar_invitation),
|
**_job_summary_payload(job, calendar_invitation=calendar_invitation, recovery=recovery),
|
||||||
"message_id_header": job.message_id_header,
|
"message_id_header": job.message_id_header,
|
||||||
"issues": job.issues_snapshot or [],
|
"issues": job.issues_snapshot or [],
|
||||||
"attachments": public_campaign_payload(job.resolved_attachments or []),
|
"attachments": public_campaign_payload(job.resolved_attachments or []),
|
||||||
@@ -561,13 +595,13 @@ def _review_metadata_counts(
|
|||||||
bulk_acceptable_count = 0
|
bulk_acceptable_count = 0
|
||||||
for entry_id, entry_index, build_status, validation_status in review_rows:
|
for entry_id, entry_index, build_status, validation_status in review_rows:
|
||||||
key = str(entry_id or entry_index)
|
key = str(entry_id or entry_index)
|
||||||
if build_status != "built" or validation_status == "blocked":
|
if validation_status == "blocked" or (build_status != "built" and validation_status not in {"excluded", "inactive"}):
|
||||||
blocking_count += 1
|
blocking_count += 1
|
||||||
if validation_status == "needs_review":
|
if validation_status == "needs_review":
|
||||||
required_count += 1
|
required_count += 1
|
||||||
if key in reviewed_keys:
|
if key in reviewed_keys:
|
||||||
reviewed_required_count += 1
|
reviewed_required_count += 1
|
||||||
elif validation_status in {"warning", "excluded"}:
|
elif validation_status == "warning":
|
||||||
bulk_acceptable_count += 1
|
bulk_acceptable_count += 1
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -636,6 +670,22 @@ CAMPAIGN_JOB_GRID_LIST_FILTERS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_recipient_search_expression(pattern: str):
|
||||||
|
return or_(
|
||||||
|
CampaignJob.recipient_email.ilike(pattern, escape="\\"),
|
||||||
|
CampaignJob.entry_id.ilike(pattern, escape="\\"),
|
||||||
|
*(
|
||||||
|
cast(CampaignJob.resolved_recipients[group], String).ilike(pattern, escape="\\")
|
||||||
|
for group in ("to", "cc", "bcc")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _job_page_recovery_metadata(session: Session, jobs: list[CampaignJob]) -> dict[str, dict[str, object]]:
|
||||||
|
active = [job for job in jobs if job.send_status in {"claimed", "sending"} or job.imap_status == "appending"]
|
||||||
|
return job_recovery_metadata(session, active) if active else {}
|
||||||
|
|
||||||
|
|
||||||
def _campaign_jobs_grid_filter_expressions(
|
def _campaign_jobs_grid_filter_expressions(
|
||||||
grid_filters: dict[str, str] | None,
|
grid_filters: dict[str, str] | None,
|
||||||
) -> list[object]:
|
) -> list[object]:
|
||||||
@@ -645,10 +695,7 @@ def _campaign_jobs_grid_filter_expressions(
|
|||||||
if recipient:
|
if recipient:
|
||||||
pattern = _contains_pattern(recipient)
|
pattern = _contains_pattern(recipient)
|
||||||
expressions.append(
|
expressions.append(
|
||||||
or_(
|
_campaign_recipient_search_expression(pattern)
|
||||||
CampaignJob.recipient_email.ilike(pattern, escape="\\"),
|
|
||||||
CampaignJob.entry_id.ilike(pattern, escape="\\"),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
subject = values.get("subject", "").strip()
|
subject = values.get("subject", "").strip()
|
||||||
if subject:
|
if subject:
|
||||||
@@ -801,12 +848,11 @@ def _campaign_jobs_query_context(
|
|||||||
if imap_status:
|
if imap_status:
|
||||||
filtered.append(CampaignJob.imap_status.in_(imap_status))
|
filtered.append(CampaignJob.imap_status.in_(imap_status))
|
||||||
if query_text and query_text.strip():
|
if query_text and query_text.strip():
|
||||||
pattern = f"%{query_text.strip()}%"
|
pattern = _contains_pattern(query_text.strip())
|
||||||
filtered.append(
|
filtered.append(
|
||||||
or_(
|
or_(
|
||||||
CampaignJob.recipient_email.ilike(pattern),
|
_campaign_recipient_search_expression(pattern),
|
||||||
CampaignJob.subject.ilike(pattern),
|
CampaignJob.subject.ilike(pattern, escape="\\"),
|
||||||
CampaignJob.entry_id.ilike(pattern),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
filtered.extend(_campaign_jobs_grid_filter_expressions(grid_filters))
|
filtered.extend(_campaign_jobs_grid_filter_expressions(grid_filters))
|
||||||
@@ -875,12 +921,14 @@ def _campaign_jobs_page_response(
|
|||||||
if changed_job_ids is not None:
|
if changed_job_ids is not None:
|
||||||
jobs = [job for job in jobs if job.id in changed_job_ids]
|
jobs = [job for job in jobs if job.id in changed_job_ids]
|
||||||
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
|
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
|
||||||
|
recovery = _job_page_recovery_metadata(session, jobs)
|
||||||
return CampaignJobsResponse(
|
return CampaignJobsResponse(
|
||||||
jobs=[
|
jobs=[
|
||||||
_job_summary_payload(
|
_job_summary_payload(
|
||||||
job,
|
job,
|
||||||
reviewed_keys=reviewed_keys,
|
reviewed_keys=reviewed_keys,
|
||||||
calendar_invitation=calendar_invitations.get(job.id),
|
calendar_invitation=calendar_invitations.get(job.id),
|
||||||
|
recovery=recovery.get(job.id),
|
||||||
)
|
)
|
||||||
for job in jobs
|
for job in jobs
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Shared, side-effect-free eligibility for individual and grouped review."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def review_decision_metadata(job: Any) -> dict[str, Any]:
|
||||||
|
issues = [item for item in (job.issues_snapshot or []) if isinstance(item, dict)]
|
||||||
|
eligible = (
|
||||||
|
getattr(job, "build_status", "built") == "built"
|
||||||
|
and job.validation_status == "needs_review"
|
||||||
|
and not any(str(item.get("behavior") or "").lower() == "block" for item in issues)
|
||||||
|
)
|
||||||
|
reviewable = [item for item in issues if str(item.get("behavior") or "").lower() == "ask"]
|
||||||
|
evidence = reviewable or issues
|
||||||
|
categories = sorted({
|
||||||
|
(str(item.get("code") or ""), str(item.get("behavior") or ""), str(item.get("source") or ""))
|
||||||
|
for item in evidence
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"eligible": eligible,
|
||||||
|
"category_key": hashlib.sha256(json.dumps(categories, separators=(",", ":")).encode()).hexdigest() if eligible else "",
|
||||||
|
"reason_required": eligible and any(str(item.get("source") or "").startswith("attachments") for item in reviewable),
|
||||||
|
"issue_codes": sorted({str(item.get("code")) for item in evidence if item.get("code")}),
|
||||||
|
}
|
||||||
@@ -159,9 +159,9 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
|||||||
cases = {
|
cases = {
|
||||||
"block": ("build_failed", "blocked", 0, "block", False),
|
"block": ("build_failed", "blocked", 0, "block", False),
|
||||||
"ask": ("built", "needs_review", 0, "ask", True),
|
"ask": ("built", "needs_review", 0, "ask", True),
|
||||||
"drop": ("built", "needs_review", 0, "ask", True),
|
"drop": ("built", "excluded", 0, "drop", True),
|
||||||
"warn": ("built", "warning", 1, "warn", True),
|
"warn": ("built", "warning", 1, "warn", True),
|
||||||
"continue": ("built", "warning", 1, None, True),
|
"continue": ("built", "ready", 1, None, True),
|
||||||
}
|
}
|
||||||
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
|
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
|
||||||
with self.subTest(behavior=behavior):
|
with self.subTest(behavior=behavior):
|
||||||
@@ -257,6 +257,41 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(issue.behavior, "warn")
|
self.assertEqual(issue.behavior, "warn")
|
||||||
|
|
||||||
|
def test_explicit_optional_empty_rule_is_information_unless_hard_blocked(self) -> None:
|
||||||
|
for policy, expected in (("warn", "ready"), ("ask", "ready"), ("block", "blocked")):
|
||||||
|
with self.subTest(policy=policy), 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="continue", configure_missing_rule=True)
|
||||||
|
config.validation_policy.missing_optional_attachment = policy
|
||||||
|
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||||
|
message = result.report.messages[0]
|
||||||
|
self.assertEqual(message.validation_status.value, expected)
|
||||||
|
issue = next(item for item in message.issues if item.code == "missing_optional_attachment")
|
||||||
|
self.assertEqual(issue.behavior, "block" if policy == "block" else "continue")
|
||||||
|
self.assertEqual(issue.severity, "error" if policy == "block" else "info")
|
||||||
|
self.assertEqual(message.attachments[0].matches, [])
|
||||||
|
|
||||||
|
def test_deliberate_rule_drop_is_excluded_but_cannot_override_required_block(self) -> None:
|
||||||
|
for required, expected in ((False, "excluded"), (True, "blocked")):
|
||||||
|
with self.subTest(required=required), 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="continue", configure_missing_rule=True)
|
||||||
|
rule = config.attachments.global_[0]
|
||||||
|
rule.missing_behavior = "drop"
|
||||||
|
rule.required = required
|
||||||
|
result = build_campaign_messages(config, campaign_file=campaign_file, output_dir=root / "out", write_eml=True)
|
||||||
|
message = result.report.messages[0]
|
||||||
|
self.assertEqual(message.validation_status.value, expected)
|
||||||
|
self.assertEqual(result.report.queueable_count, 0)
|
||||||
|
if not required:
|
||||||
|
self.assertEqual(message.send_status.value, "skipped")
|
||||||
|
self.assertEqual(message.imap_status.value, "skipped")
|
||||||
|
self.assertEqual(message.issues[0].behavior, "drop")
|
||||||
|
|
||||||
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
|
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_IMPORT_PROGRAM = """
|
||||||
|
import importlib
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, sys.argv[1])
|
||||||
|
|
||||||
|
def deny_network(*args, **kwargs):
|
||||||
|
raise AssertionError("Campaign imports must not connect to external services")
|
||||||
|
|
||||||
|
class NoNetworkSocket(socket.socket):
|
||||||
|
connect = deny_network
|
||||||
|
connect_ex = deny_network
|
||||||
|
|
||||||
|
socket.create_connection = deny_network
|
||||||
|
socket.socket = NoNetworkSocket
|
||||||
|
|
||||||
|
importlib.import_module(sys.argv[2])
|
||||||
|
from govoplan_campaign.backend.campaign import (
|
||||||
|
CampaignConfig, SemanticIssue, SemanticReport,
|
||||||
|
load_campaign_config, load_campaign_json, validate_campaign_config,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
||||||
|
|
||||||
|
assert all(callable(value) for value in (
|
||||||
|
CampaignConfig, SemanticIssue, SemanticReport,
|
||||||
|
load_campaign_config, load_campaign_json, validate_campaign_config,
|
||||||
|
resolve_campaign_attachments,
|
||||||
|
))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignImportOrderTests(unittest.TestCase):
|
||||||
|
def test_model_validation_and_attachment_entry_points_import_independently(self):
|
||||||
|
source_root = Path(__file__).resolve().parents[1] / "src"
|
||||||
|
for first_module in (
|
||||||
|
"govoplan_campaign.backend.attachments.resolver",
|
||||||
|
"govoplan_campaign.backend.campaign.validation",
|
||||||
|
"govoplan_campaign.backend.campaign.entries",
|
||||||
|
):
|
||||||
|
with self.subTest(first_module=first_module):
|
||||||
|
completed = subprocess.run(
|
||||||
|
[sys.executable, "-I", "-c", _IMPORT_PROGRAM, str(source_root), first_module],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
self.assertEqual(0, completed.returncode, completed.stderr or completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -160,6 +160,42 @@ class CampaignOptimisticConcurrencyTests(unittest.TestCase):
|
|||||||
assert current is not None
|
assert current is not None
|
||||||
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
|
self.assertEqual(current.raw_json["campaign"]["name"], "First writer")
|
||||||
|
|
||||||
|
def test_metadata_save_does_not_erase_recorded_review(self) -> None:
|
||||||
|
review = {
|
||||||
|
"build_token": "server-build-token",
|
||||||
|
"inspection_complete": True,
|
||||||
|
"reviewed_message_keys": ["recipient-1"],
|
||||||
|
"issue_decisions": [],
|
||||||
|
"updated_at": "2026-07-21T00:00:00+00:00",
|
||||||
|
"updated_by_user_id": "reviewer-1",
|
||||||
|
}
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
version = session.get(CampaignVersion, "version-1")
|
||||||
|
assert version is not None
|
||||||
|
version.editor_state = {
|
||||||
|
"created_from": "minimal_campaign",
|
||||||
|
"review_send": review,
|
||||||
|
}
|
||||||
|
session.commit()
|
||||||
|
saved = update_campaign_version(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
version_id="version-1",
|
||||||
|
editor_state={"opt_ins": {"inline_guidance": False}},
|
||||||
|
expected_revision=version.edit_revision,
|
||||||
|
autosave=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(saved.editor_state["review_send"], review)
|
||||||
|
|
||||||
|
with self.SessionLocal() as verification:
|
||||||
|
current = verification.get(CampaignVersion, "version-1")
|
||||||
|
assert current is not None
|
||||||
|
self.assertEqual(current.editor_state, {
|
||||||
|
"opt_ins": {"inline_guidance": False},
|
||||||
|
"review_send": review,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -223,8 +223,9 @@ def test_queue_projection_fails_closed_if_no_task_was_published() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("duplicate_mutates", [False, True])
|
||||||
def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
||||||
monkeypatch,
|
monkeypatch, duplicate_mutates,
|
||||||
) -> None:
|
) -> None:
|
||||||
first_worker = mock.Mock()
|
first_worker = mock.Mock()
|
||||||
first_worker.received_task_ids.return_value = (TASK_ID,)
|
first_worker.received_task_ids.return_value = (TASK_ID,)
|
||||||
@@ -232,22 +233,29 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
|||||||
replacement_worker.received_task_ids.return_value = (TASK_ID,)
|
replacement_worker.received_task_ids.return_value = (TASK_ID,)
|
||||||
workers = iter([first_worker, replacement_worker])
|
workers = iter([first_worker, replacement_worker])
|
||||||
endpoint = _Endpoint()
|
endpoint = _Endpoint()
|
||||||
durable_states = iter(
|
durable_states = [
|
||||||
[
|
{
|
||||||
{
|
"job_count": 1,
|
||||||
"job_count": 1,
|
"send_status_counts": {"sending": 1},
|
||||||
"send_status_counts": {"sending": 1},
|
"attempt_status_counts": {"smtp_in_progress": 1},
|
||||||
"attempt_status_counts": {"smtp_in_progress": 1},
|
"unfinished_attempt_count": 1,
|
||||||
"unfinished_attempt_count": 1,
|
},
|
||||||
},
|
{
|
||||||
{
|
"job_count": 1,
|
||||||
"job_count": 1,
|
"send_status_counts": {"sending": 1},
|
||||||
"send_status_counts": {"outcome_unknown": 1},
|
"attempt_status_counts": {"smtp_in_progress": 1},
|
||||||
"attempt_status_counts": {"outcome_unknown": 1},
|
"unfinished_attempt_count": 1,
|
||||||
"unfinished_attempt_count": 0,
|
},
|
||||||
},
|
{
|
||||||
]
|
"job_count": 1,
|
||||||
)
|
"send_status_counts": {"outcome_unknown": 1},
|
||||||
|
"attempt_status_counts": {"outcome_unknown": 1},
|
||||||
|
"unfinished_attempt_count": 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if duplicate_mutates:
|
||||||
|
durable_states[1] = durable_states[2]
|
||||||
|
durable_states = iter(durable_states)
|
||||||
prepared = SimpleNamespace(
|
prepared = SimpleNamespace(
|
||||||
campaign_id="campaign-internal",
|
campaign_id="campaign-internal",
|
||||||
version_id="version-internal",
|
version_id="version-internal",
|
||||||
@@ -277,24 +285,34 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
|||||||
"_wait_for_broker_drained",
|
"_wait_for_broker_drained",
|
||||||
lambda *args, **kwargs: runner.RedisBrokerState(0, 0, 0),
|
lambda *args, **kwargs: runner.RedisBrokerState(0, 0, 0),
|
||||||
)
|
)
|
||||||
|
recover_claim = mock.Mock(return_value={"explicit_fenced_recovery": True})
|
||||||
|
|
||||||
evidence = runner.execute_redelivery_scenario(
|
def execute():
|
||||||
_Client(),
|
return runner.execute_redelivery_scenario(
|
||||||
{"Authorization": "not-retained"},
|
_Client(),
|
||||||
fixture_path=FIXTURE_PATH,
|
{"Authorization": "not-retained"},
|
||||||
settings=_settings(),
|
fixture_path=FIXTURE_PATH,
|
||||||
endpoint=endpoint,
|
settings=_settings(),
|
||||||
redis_url="redis://127.0.0.1:36379/0",
|
endpoint=endpoint,
|
||||||
runtime_root=Path("/not-used"),
|
redis_url="redis://127.0.0.1:36379/0",
|
||||||
snapshot_probe=lambda _version_id: ({}, {}),
|
runtime_root=Path("/not-used"),
|
||||||
audit_probe=lambda _campaign_id, _version_id: {
|
snapshot_probe=lambda _version_id: ({}, {}),
|
||||||
"campaign.created": 1,
|
audit_probe=lambda _campaign_id, _version_id: {
|
||||||
"campaign.validated": 1,
|
"campaign.created": 1,
|
||||||
"campaign.messages_built": 1,
|
"campaign.validated": 1,
|
||||||
"campaign.queued": 1,
|
"campaign.messages_built": 1,
|
||||||
},
|
"campaign.queued": 1,
|
||||||
delivery_probe=lambda _campaign_id, _version_id: next(durable_states),
|
},
|
||||||
)
|
delivery_probe=lambda _campaign_id, _version_id: next(durable_states),
|
||||||
|
recover_claim=recover_claim,
|
||||||
|
)
|
||||||
|
|
||||||
|
if duplicate_mutates:
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="without stopped-runtime proof"):
|
||||||
|
execute()
|
||||||
|
recover_claim.assert_not_called()
|
||||||
|
return
|
||||||
|
evidence = execute()
|
||||||
|
|
||||||
assert evidence["broker"] == {
|
assert evidence["broker"] == {
|
||||||
"transport": "redis",
|
"transport": "redis",
|
||||||
@@ -310,5 +328,8 @@ def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
|||||||
assert evidence["recovered_durable_state"]["send_status_counts"] == {
|
assert evidence["recovered_durable_state"]["send_status_counts"] == {
|
||||||
"outcome_unknown": 1
|
"outcome_unknown": 1
|
||||||
}
|
}
|
||||||
|
assert evidence["redelivered_durable_state"] == evidence["interrupted_durable_state"]
|
||||||
|
assert evidence["supervision"]["duplicate_task_left_sending_unchanged"] is True
|
||||||
|
recover_claim.assert_called_once_with("campaign-internal", "version-internal", first_worker.process)
|
||||||
assert TASK_ID not in json.dumps(evidence, sort_keys=True)
|
assert TASK_ID not in json.dumps(evidence, sort_keys=True)
|
||||||
assert endpoint.release_count >= 1
|
assert endpoint.release_count >= 1
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.admin.models import SystemSettings
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.configuration_safety import classify_configuration_field, plan_configuration_change
|
||||||
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
|
from govoplan_campaign.backend.delivery_policy import SYNCHRONOUS_SEND_MAX_ENV, effective_synchronous_send_policy
|
||||||
|
from govoplan_campaign.backend.routes import delivery_settings as routes
|
||||||
|
from govoplan_campaign.backend.router import router as campaign_router
|
||||||
|
|
||||||
|
|
||||||
|
def principal(*scopes, tenant="tenant-a"):
|
||||||
|
actor = SimpleNamespace(id="admin-1")
|
||||||
|
return ApiPrincipal(principal=PrincipalRef(account_id=actor.id, membership_id=actor.id, tenant_id=tenant, scopes=frozenset(scopes)), user=actor, account=actor)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def policy(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv(SYNCHRONOUS_SEND_MAX_ENV, raising=False)
|
||||||
|
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'policy.db'}")
|
||||||
|
for table in (SystemSettings.__table__, Tenant.__table__, ChangeSequenceEntry.__table__):
|
||||||
|
table.create(engine)
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.add_all([
|
||||||
|
SystemSettings(id="global", settings={"unrelated": {"enabled": True}}),
|
||||||
|
Tenant(id="tenant-a", slug="a", name="A", settings={"unrelated": "preserve"}),
|
||||||
|
Tenant(id="tenant-b", slug="b", name="B", settings={}),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
with patch.object(routes, "audit_from_principal", autospec=True) as audit:
|
||||||
|
yield SimpleNamespace(session=session, engine=engine, audit=audit,
|
||||||
|
admin=principal("system:settings:read", "system:settings:write", "admin:policies:read", "admin:policies:write"))
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def state(policy, scope="system", actor=None):
|
||||||
|
return routes.read_delivery_policy(scope, session=policy.session, principal=actor or policy.admin)
|
||||||
|
|
||||||
|
|
||||||
|
def save(policy, value, scope="system", revision=None, actor=None):
|
||||||
|
payload = routes.DeliveryPolicyUpdate(synchronous_send_max_recipients=value, expected_revision=revision or state(policy, scope)["revision"])
|
||||||
|
return routes.update_delivery_policy(scope, payload, session=policy.session, principal=actor or policy.admin)
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_admin_can_raise_implicit_default_and_save_is_durable_audited_and_scoped(policy):
|
||||||
|
before = state(policy)
|
||||||
|
assert before["effective_max_recipients"] == 25 and before["max_configurable_recipients"] == 500
|
||||||
|
result = save(policy, 200)
|
||||||
|
assert result["effective_max_recipients"] == 200 and result["revision"] != before["revision"]
|
||||||
|
with Session(policy.engine) as fresh:
|
||||||
|
assert effective_synchronous_send_policy(fresh, tenant_id="tenant-a", environ={}).max_recipient_jobs == 200
|
||||||
|
system = fresh.get(SystemSettings, "global")
|
||||||
|
assert system.settings["unrelated"] == {"enabled": True}
|
||||||
|
history = system.settings["_configuration_control"]["history"]
|
||||||
|
assert len(history) == 1 and history[0]["key"] == "campaign_delivery_policy.system"
|
||||||
|
assert history[0]["before"]["synchronous_send_max_recipients"] is None
|
||||||
|
assert history[0]["after"]["synchronous_send_max_recipients"] == 200
|
||||||
|
assert history[0]["actor_user_id"] == "admin-1"
|
||||||
|
assert policy.audit.call_args.kwargs["commit"] is False
|
||||||
|
assert policy.audit.call_args.kwargs["scope"] == "system"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tenant_can_only_narrow_and_reset_inherits_without_affecting_other_tenant(policy):
|
||||||
|
save(policy, 200)
|
||||||
|
with pytest.raises(HTTPException) as failure:
|
||||||
|
save(policy, 201, "tenant")
|
||||||
|
assert failure.value.status_code == 422
|
||||||
|
save(policy, 183, "tenant")
|
||||||
|
assert effective_synchronous_send_policy(policy.session, tenant_id="tenant-a").max_recipient_jobs == 183
|
||||||
|
assert effective_synchronous_send_policy(policy.session, tenant_id="tenant-b").max_recipient_jobs == 200
|
||||||
|
assert policy.session.get(Tenant, "tenant-a").settings["unrelated"] == "preserve"
|
||||||
|
assert save(policy, None, "tenant")["effective_max_recipients"] == 200
|
||||||
|
assert save(policy, None)["effective_max_recipients"] == 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_deployment_ceiling_remains_authoritative_and_zero_disables(policy, monkeypatch):
|
||||||
|
save(policy, 200)
|
||||||
|
monkeypatch.setenv(SYNCHRONOUS_SEND_MAX_ENV, "40")
|
||||||
|
assert state(policy)["effective_max_recipients"] == 40
|
||||||
|
with pytest.raises(HTTPException) as failure:
|
||||||
|
save(policy, 41)
|
||||||
|
assert failure.value.status_code == 422
|
||||||
|
assert save(policy, None)["effective_max_recipients"] == 40
|
||||||
|
assert save(policy, 0)["effective_max_recipients"] == 0
|
||||||
|
assert state(policy, "tenant")["max_configurable_recipients"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("blank", ["", " ", "\t\n"])
|
||||||
|
def test_blank_deployment_value_never_turns_default_into_absolute_maximum(policy, monkeypatch, blank):
|
||||||
|
monkeypatch.setenv(SYNCHRONOUS_SEND_MAX_ENV, blank)
|
||||||
|
assert state(policy)["effective_max_recipients"] == 25
|
||||||
|
assert state(policy)["deployment_ceiling_explicit"] is False
|
||||||
|
save(policy, 200)
|
||||||
|
assert state(policy)["effective_max_recipients"] == 200
|
||||||
|
save(policy, 183, "tenant")
|
||||||
|
assert state(policy, "tenant")["effective_max_recipients"] == 183
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_parent_own_or_aba_revision_never_overwrites_policy(policy):
|
||||||
|
initial = state(policy)
|
||||||
|
tenant = state(policy, "tenant")
|
||||||
|
save(policy, 200)
|
||||||
|
for scope, revision in (("system", initial["revision"]), ("tenant", tenant["revision"])):
|
||||||
|
with pytest.raises(HTTPException) as failure:
|
||||||
|
save(policy, 10, scope, revision=revision)
|
||||||
|
assert failure.value.status_code == 409
|
||||||
|
save(policy, None)
|
||||||
|
with pytest.raises(HTTPException) as failure:
|
||||||
|
save(policy, 30, revision=initial["revision"])
|
||||||
|
assert failure.value.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("scope,scopes,operation", [
|
||||||
|
("system", ("admin:policies:read", "admin:policies:write"), "read"),
|
||||||
|
("system", ("admin:policies:write",), "write"),
|
||||||
|
("tenant", ("system:settings:read", "system:settings:write"), "read"),
|
||||||
|
("tenant", ("system:settings:write",), "write"),
|
||||||
|
("system", ("system:settings:read",), "write"),
|
||||||
|
("tenant", ("admin:policies:read",), "write"),
|
||||||
|
])
|
||||||
|
def test_permissions_cannot_cross_scope_or_use_read_permission_to_write(policy, scope, scopes, operation):
|
||||||
|
actor = principal(*scopes)
|
||||||
|
with pytest.raises(HTTPException) as failure:
|
||||||
|
if operation == "read": state(policy, scope, actor=actor)
|
||||||
|
else: save(policy, 10, scope, actor=actor)
|
||||||
|
assert failure.value.status_code == 403
|
||||||
|
assert "_configuration_control" not in policy.session.get(SystemSettings, "global").settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [True, 1.5, "20", -1, 501])
|
||||||
|
def test_schema_rejects_coercions_and_unbounded_values(value):
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
routes.DeliveryPolicyUpdate(synchronous_send_max_recipients=value, expected_revision="a" * 64)
|
||||||
|
|
||||||
|
|
||||||
|
def test_route_rolls_back_configuration_and_history_if_audit_fails(policy):
|
||||||
|
policy.audit.side_effect = RuntimeError("Audit unavailable")
|
||||||
|
with pytest.raises(RuntimeError, match="Audit unavailable"):
|
||||||
|
save(policy, 200)
|
||||||
|
policy.session.expire_all()
|
||||||
|
assert policy.session.get(SystemSettings, "global").settings == {"unrelated": {"enabled": True}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_configuration_catalog_supports_only_known_scope_keys_and_scopes():
|
||||||
|
for scope, permission in (("system", "system:settings:write"), ("tenant", "admin:policies:write")):
|
||||||
|
key = f"campaign_delivery_policy.{scope}"
|
||||||
|
field = classify_configuration_field(key)
|
||||||
|
assert field.owner_module == "campaigns" and field.rollback_history_required
|
||||||
|
assert plan_configuration_change(key, actor_scopes=(permission,), value={"synchronous_send_max_recipients": 200}).allowed
|
||||||
|
assert not plan_configuration_change(key, actor_scopes=(), value={"synchronous_send_max_recipients": 200}).allowed
|
||||||
|
assert classify_configuration_field("campaign_delivery_policy.unknown") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_registered_http_routes_validate_scope_authorization_payload_and_revision(policy):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(campaign_router, prefix="/api/v1")
|
||||||
|
app.dependency_overrides[get_session] = lambda: policy.session
|
||||||
|
actor = [policy.admin]
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: actor[0]
|
||||||
|
with TestClient(app) as client:
|
||||||
|
path = "/api/v1/campaigns/settings/delivery-policy/system"
|
||||||
|
response = client.get(path)
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = {"synchronous_send_max_recipients": 200, "expected_revision": response.json()["revision"]}
|
||||||
|
actor[0] = principal("system:settings:read")
|
||||||
|
assert client.put(path, json=payload).status_code == 403
|
||||||
|
actor[0] = principal("admin:policies:read", "admin:policies:write")
|
||||||
|
assert client.get(path).status_code == 403
|
||||||
|
assert client.put(path, json=payload).status_code == 403
|
||||||
|
actor[0] = policy.admin
|
||||||
|
assert client.put(path, json={**payload, "settings": {"unrelated": "overwrite"}}).status_code == 422
|
||||||
|
assert client.put(path, json={**payload, "synchronous_send_max_recipients": True}).status_code == 422
|
||||||
|
saved = client.put(path, json=payload)
|
||||||
|
assert saved.status_code == 200 and saved.json()["effective_max_recipients"] == 200
|
||||||
|
assert client.put(path, json=payload).status_code == 409
|
||||||
|
assert client.get("/api/v1/campaigns/settings/delivery-policy/user").status_code == 422
|
||||||
|
actor[0] = principal()
|
||||||
|
assert client.get(path).status_code == 403
|
||||||
|
assert policy.audit.call_args.kwargs["object_type"] == "campaign_delivery_policy"
|
||||||
@@ -6,7 +6,9 @@ import pytest
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
CampaignMailProfileBoundaryError,
|
||||||
campaign_editor_state_for_edit,
|
campaign_editor_state_for_edit,
|
||||||
|
campaign_editor_state_with_client_update,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.schemas import (
|
from govoplan_campaign.backend.schemas import (
|
||||||
CampaignVersionResponse,
|
CampaignVersionResponse,
|
||||||
@@ -19,6 +21,7 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
[
|
[
|
||||||
{"smtp": {"host": "smtp.example.test", "password": "secret"}},
|
{"smtp": {"host": "smtp.example.test", "password": "secret"}},
|
||||||
{"transport": {"imap_password": "secret"}},
|
{"transport": {"imap_password": "secret"}},
|
||||||
|
{"approval_gate": {"request_id": "forged"}},
|
||||||
{
|
{
|
||||||
"review_send": {
|
"review_send": {
|
||||||
"build_token": "forged",
|
"build_token": "forged",
|
||||||
@@ -108,3 +111,48 @@ def test_fork_copy_keeps_only_client_owned_bounded_metadata() -> None:
|
|||||||
"field_overrides": {"department": False},
|
"field_overrides": {"department": False},
|
||||||
}
|
}
|
||||||
assert "legacy-secret" not in repr(copied)
|
assert "legacy-secret" not in repr(copied)
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_metadata_update_preserves_only_trusted_server_evidence() -> None:
|
||||||
|
review = {
|
||||||
|
"build_token": "server-build-token",
|
||||||
|
"inspection_complete": True,
|
||||||
|
"reviewed_message_keys": ["entry-1"],
|
||||||
|
"issue_decisions": [],
|
||||||
|
"updated_at": "2026-07-21T00:00:00+00:00",
|
||||||
|
"updated_by_user_id": "reviewer-1",
|
||||||
|
}
|
||||||
|
approval = {
|
||||||
|
"request_id": "approval-1",
|
||||||
|
"request_revision": 2,
|
||||||
|
"subject_version": "version-1",
|
||||||
|
"subject_digest": "a" * 64,
|
||||||
|
"requested_at": "2026-07-21T00:00:00+00:00",
|
||||||
|
"requested_by_user_id": "approver-1",
|
||||||
|
}
|
||||||
|
stored = {
|
||||||
|
"created_from": "minimal_campaign",
|
||||||
|
"opt_ins": {"inline_guidance": True},
|
||||||
|
"review_send": review,
|
||||||
|
"approval_gate": approval,
|
||||||
|
"credentials": {"password": "legacy-secret"},
|
||||||
|
}
|
||||||
|
updated = campaign_editor_state_with_client_update(
|
||||||
|
stored, {"opt_ins": {"inline_guidance": False}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated == {
|
||||||
|
"opt_ins": {"inline_guidance": False},
|
||||||
|
"review_send": review,
|
||||||
|
"approval_gate": approval,
|
||||||
|
}
|
||||||
|
assert updated["review_send"] is not review
|
||||||
|
assert updated["approval_gate"] is not approval
|
||||||
|
assert stored["opt_ins"] == {"inline_guidance": True}
|
||||||
|
assert "legacy-secret" not in repr(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("server_key", ["review_send", "approval_gate"])
|
||||||
|
def test_client_metadata_update_cannot_replace_server_evidence(server_key: str) -> None:
|
||||||
|
with pytest.raises(CampaignMailProfileBoundaryError, match="unsupported"):
|
||||||
|
campaign_editor_state_with_client_update({}, {server_key: {}})
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryabl
|
|||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.get.return_value = job
|
session.get.return_value = job
|
||||||
session.query.return_value.filter.return_value.order_by.return_value.first.return_value = attempt
|
session.query.return_value.filter.return_value.order_by.return_value.first.return_value = attempt
|
||||||
|
session.query.return_value.filter.return_value.update.return_value = 1
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.integrations import ImapAppendError, MailCampaignIntegration
|
||||||
|
|
||||||
|
|
||||||
|
def test_older_mail_capability_keeps_single_message_compatibility():
|
||||||
|
integration = MailCampaignIntegration(SimpleNamespace())
|
||||||
|
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign") as state:
|
||||||
|
assert state is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_batch_forwards_scope_and_cleans_up_when_caller_fails():
|
||||||
|
calls = []
|
||||||
|
state = SimpleNamespace(connection_count=0, reconnect_count=0)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def batch(**kwargs):
|
||||||
|
calls.append(kwargs)
|
||||||
|
try:
|
||||||
|
yield state
|
||||||
|
finally:
|
||||||
|
calls.append("closed")
|
||||||
|
|
||||||
|
integration = MailCampaignIntegration(SimpleNamespace(campaign_imap_batch=batch))
|
||||||
|
with pytest.raises(ValueError, match="caller"):
|
||||||
|
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign") as actual:
|
||||||
|
assert actual is state
|
||||||
|
raise ValueError("caller failure")
|
||||||
|
assert calls == [{"tenant_id": "tenant", "campaign_id": "campaign"}, "closed"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_batch_translates_unknown_outcome_without_losing_flags():
|
||||||
|
class ProviderAppendError(RuntimeError):
|
||||||
|
temporary = False
|
||||||
|
outcome_unknown = True
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def batch(**kwargs):
|
||||||
|
raise ProviderAppendError("inspect mailbox")
|
||||||
|
yield None
|
||||||
|
|
||||||
|
integration = MailCampaignIntegration(SimpleNamespace(
|
||||||
|
campaign_imap_batch=batch, ImapAppendError=ProviderAppendError,
|
||||||
|
))
|
||||||
|
with pytest.raises(ImapAppendError) as caught:
|
||||||
|
with integration.campaign_imap_batch(tenant_id="tenant", campaign_id="campaign"):
|
||||||
|
pass
|
||||||
|
assert caught.value.outcome_unknown
|
||||||
|
assert caught.value.temporary is False
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Real database/route checks: a review acceptance is durable before completion."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine, event
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||||
|
from govoplan_campaign.backend.routes import versions as routes
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignReviewStateRequest, CampaignVersionDetailResponse
|
||||||
|
from govoplan_campaign.backend.services.job_queries import _review_metadata, _review_metadata_counts
|
||||||
|
from govoplan_campaign.backend.services.review_decisions import review_decision_metadata
|
||||||
|
from govoplan_campaign.backend.sending.jobs import _reviewed_needs_review_keys
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def review(tmp_path):
|
||||||
|
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'review.db'}")
|
||||||
|
for name in ("access_users", "access_groups"):
|
||||||
|
if name not in Base.metadata.tables:
|
||||||
|
Table(name, Base.metadata, Column("id", String(36), primary_key=True))
|
||||||
|
Base.metadata.create_all(engine, tables=[
|
||||||
|
Base.metadata.tables["access_users"], Base.metadata.tables["access_groups"],
|
||||||
|
ChangeSequenceEntry.__table__, Campaign.__table__, CampaignVersion.__table__, CampaignJob.__table__,
|
||||||
|
])
|
||||||
|
with Session(engine) as session:
|
||||||
|
campaign = Campaign(id="campaign-1", tenant_id="tenant-1", external_id="C1", name="Campaign", current_version_id="version-1")
|
||||||
|
version = CampaignVersion(id="version-1", campaign_id=campaign.id, version_number=1,
|
||||||
|
raw_json={"version": "1.0", "campaign": {"id": "C1", "name": "Campaign"}},
|
||||||
|
build_summary={"build_token": "private-build-1", "built_count": 2}, editor_state={"created_from": "minimal_campaign"})
|
||||||
|
session.add_all([campaign, version, _job(1), _job(2)])
|
||||||
|
session.commit()
|
||||||
|
audits = []
|
||||||
|
def audit(current_session, _principal, **kwargs):
|
||||||
|
audits.append(kwargs)
|
||||||
|
if kwargs.get("commit"):
|
||||||
|
current_session.commit()
|
||||||
|
with patch.object(routes, "_get_campaign_for_principal", return_value=campaign), patch.object(routes, "audit_from_principal", side_effect=audit):
|
||||||
|
yield SimpleNamespace(engine=engine, session=session, version=version, campaign=campaign, audits=audits)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _job(index, *, validation="needs_review", build="built", code="missing_optional_attachment", behavior="ask"):
|
||||||
|
return CampaignJob(id=f"job-{index}", tenant_id="tenant-1", campaign_id="campaign-1", campaign_version_id="version-1",
|
||||||
|
entry_index=index, entry_id=f"entry-{index}", validation_status=validation, build_status=build,
|
||||||
|
eml_sha256=str(index % 10) * 64, issues_snapshot=[{"code": code, "behavior": behavior, "source": "attachments", "details": {"rule_id": f"rule-{index}"}}])
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(actor="reviewer-1"):
|
||||||
|
return ApiPrincipal(principal=PrincipalRef(account_id=actor, membership_id=actor, tenant_id="tenant-1", scopes=frozenset({"campaigns:campaign:review"})), account=SimpleNamespace(id=actor), user=SimpleNamespace(id=actor))
|
||||||
|
|
||||||
|
|
||||||
|
def _save(review, *, ids=(1,), complete=False, actor="reviewer-1", session=None, **overrides):
|
||||||
|
payload = {
|
||||||
|
"inspection_complete": complete, "merge_progress": True,
|
||||||
|
"build_token": review.version.review_build_token, "base_revision": review.version.edit_revision,
|
||||||
|
"reviewed_message_keys": [f"entry-{index}" for index in ids],
|
||||||
|
"issue_decisions": [{"job_id": f"job-{index}", "decision": "accept", "reason": f"Reason {index}"} for index in ids],
|
||||||
|
**overrides,
|
||||||
|
}
|
||||||
|
return routes.set_version_review_state("campaign-1", "version-1", CampaignReviewStateRequest(**payload), session=session or review.session, principal=_principal(actor))
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_reason_and_reviewed_state_survive_fresh_reload_without_completion(review):
|
||||||
|
response = _save(review)
|
||||||
|
review.session.expire_all()
|
||||||
|
stored = review.session.get(CampaignVersion, "version-1")
|
||||||
|
state = stored.editor_state["review_send"]
|
||||||
|
assert state["inspection_complete"] is False
|
||||||
|
assert state["reviewed_message_keys"] == ["entry-1"]
|
||||||
|
assert state["issue_decisions"][0]["reason"] == "Reason 1"
|
||||||
|
assert state["issue_decisions"][0]["actor_user_id"] == "reviewer-1"
|
||||||
|
assert state["issue_decisions"][0]["message_sha256"] == "1" * 64
|
||||||
|
assert response.edit_revision == 2
|
||||||
|
assert response.review_build_token and "private-build-1" not in repr(response)
|
||||||
|
public_state = response.editor_state["review_send"]
|
||||||
|
assert public_state["review_build_token"] == response.review_build_token
|
||||||
|
assert "build_token" not in public_state and "build_token" not in (response.build_summary or {})
|
||||||
|
metadata, reviewed_keys = _review_metadata(review.session, stored, [CampaignJob.campaign_version_id == stored.id])
|
||||||
|
assert metadata["reviewed_required_count"] == 1 and reviewed_keys == {"entry-1"}
|
||||||
|
assert _reviewed_needs_review_keys(stored) == set(), "partial progress never authorizes delivery"
|
||||||
|
assert review.audits[-1]["action"] == "campaign.message_review_updated"
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_reviewer_and_final_completion_preserve_first_decision_evidence(review):
|
||||||
|
_save(review)
|
||||||
|
first = copy.deepcopy(review.version.editor_state["review_send"]["issue_decisions"][0])
|
||||||
|
_save(review, ids=(2,), actor="reviewer-2")
|
||||||
|
state = review.version.editor_state["review_send"]
|
||||||
|
assert state["reviewed_message_keys"] == ["entry-1", "entry-2"]
|
||||||
|
assert state["issue_decisions"][0] == first
|
||||||
|
second = copy.deepcopy(state["issue_decisions"][1])
|
||||||
|
assert review.audits[-1]["details"]["issue_decisions"]["count"] == 1
|
||||||
|
assert review.audits[-1]["details"]["issue_decisions"] == routes._review_decision_audit_evidence(review.version, job_ids={"job-2"})
|
||||||
|
_save(review, ids=(), complete=True, actor="reviewer-3")
|
||||||
|
review.session.expire_all()
|
||||||
|
state = review.version.editor_state["review_send"]
|
||||||
|
assert state["inspection_complete"] is True
|
||||||
|
assert state["issue_decisions"] == [first, second]
|
||||||
|
assert review.audits[-1]["details"]["issue_decisions"]["count"] == 2
|
||||||
|
assert _reviewed_needs_review_keys(review.version) == {"entry-1", "entry-2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_save_loads_only_selected_job_and_never_materializes_files(review):
|
||||||
|
review.session.add_all([_job(index, validation="ready", behavior="continue") for index in range(3, 503)])
|
||||||
|
review.session.commit()
|
||||||
|
review.session.refresh(review.version)
|
||||||
|
review.session.refresh(review.campaign)
|
||||||
|
review.session.expunge_all()
|
||||||
|
loaded_jobs = []
|
||||||
|
def loaded(_session, instance):
|
||||||
|
if isinstance(instance, CampaignJob):
|
||||||
|
loaded_jobs.append(instance.id)
|
||||||
|
event.listen(review.session, "loaded_as_persistent", loaded)
|
||||||
|
try:
|
||||||
|
with patch("govoplan_campaign.backend.persistence.campaigns.load_version_config", side_effect=AssertionError("Review progress must not rebuild or resolve Files")):
|
||||||
|
_save(review)
|
||||||
|
assert loaded_jobs == ["job-1"]
|
||||||
|
finally:
|
||||||
|
event.remove(review.session, "loaded_as_persistent", loaded)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutation", ["unknown_job", "unknown_key", "missing_reason", "duplicate", "blocked", "hard_issue", "excluded", "category"])
|
||||||
|
def test_invalid_incremental_acceptance_is_atomic(review, mutation):
|
||||||
|
options = {}
|
||||||
|
if mutation == "unknown_job":
|
||||||
|
options["issue_decisions"] = [{"job_id": "outside-build", "reason": "Not allowed"}]
|
||||||
|
elif mutation == "unknown_key":
|
||||||
|
options["reviewed_message_keys"] = ["outside-build"]
|
||||||
|
elif mutation == "missing_reason":
|
||||||
|
options["issue_decisions"] = [{"job_id": "job-1", "reason": " "}]
|
||||||
|
elif mutation == "duplicate":
|
||||||
|
options["issue_decisions"] = [{"job_id": "job-1", "reason": "A"}, {"job_id": "job-1", "reason": "B"}]
|
||||||
|
elif mutation == "category":
|
||||||
|
options["decision_category_key"] = "wrong-category"
|
||||||
|
else:
|
||||||
|
job = review.session.get(CampaignJob, "job-1")
|
||||||
|
if mutation == "hard_issue":
|
||||||
|
job.issues_snapshot = [{"code": "required", "source": "attachments", "behavior": "block"}]
|
||||||
|
else:
|
||||||
|
job.validation_status = mutation
|
||||||
|
review.session.commit()
|
||||||
|
original = copy.deepcopy(review.version.editor_state)
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
_save(review, **options)
|
||||||
|
assert error.value.status_code == 422
|
||||||
|
review.session.expire_all()
|
||||||
|
assert review.version.editor_state == original and review.version.edit_revision == 1
|
||||||
|
assert review.audits == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("precondition", ["build", "revision"])
|
||||||
|
def test_stale_progress_is_conflict_and_preserves_acknowledged_progress(review, precondition):
|
||||||
|
_save(review)
|
||||||
|
options = {"build_token": "old-build"} if precondition == "build" else {"base_revision": 1}
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
_save(review, ids=(2,), **options)
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_category_bulk_is_bound_to_exact_selected_jobs(review):
|
||||||
|
category = review_decision_metadata(review.session.get(CampaignJob, "job-1"))["category_key"]
|
||||||
|
result = _save(review, ids=(1, 2), decision_category_key=category)
|
||||||
|
assert result.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1", "entry-2"]
|
||||||
|
assert len(result.editor_state["review_send"]["issue_decisions"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliberately_excluded_and_inactive_rows_do_not_block_completion_or_need_bulk_acceptance(review):
|
||||||
|
review.session.add_all([_job(3, validation="excluded", build="skipped", behavior="drop"), _job(4, validation="inactive", build="skipped", behavior="continue")])
|
||||||
|
review.session.commit()
|
||||||
|
_save(review, ids=(1, 2), complete=True)
|
||||||
|
counts = _review_metadata_counts([(None, 3, "skipped", "excluded"), (None, 4, "skipped", "inactive")], set())
|
||||||
|
assert counts == {"blocking_count": 0, "required_count": 0, "reviewed_required_count": 0, "bulk_acceptable_count": 0}
|
||||||
|
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1", "entry-2"]
|
||||||
|
assert not review_decision_metadata(review.session.get(CampaignJob, "job-3"))["eligible"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_final_review_cannot_override_remaining_hard_blockers(review):
|
||||||
|
review.session.add(_job(3, validation="blocked", build="build_failed", behavior="block"))
|
||||||
|
review.session.commit()
|
||||||
|
_save(review, ids=(1, 2))
|
||||||
|
with pytest.raises(HTTPException, match="Blocked or failed"):
|
||||||
|
_save(review, ids=(), complete=True)
|
||||||
|
assert review.version.editor_state["review_send"]["inspection_complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_frozen_blocker_is_not_reclassified_from_current_allowed_empty_settings(review):
|
||||||
|
job = review.session.get(CampaignJob, "job-1")
|
||||||
|
job.validation_status = "blocked"
|
||||||
|
job.build_status = "build_failed"
|
||||||
|
job.issues_snapshot = [{"code": "missing_required_attachment", "source": "attachments", "behavior": "block"}]
|
||||||
|
review.version.raw_json = {**review.version.raw_json, "attachments": {"missing_behavior": "continue", "send_without_attachments_behavior": "continue"}}
|
||||||
|
review.session.commit()
|
||||||
|
evidence = copy.deepcopy(job.issues_snapshot)
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_save(review, ids=(1,))
|
||||||
|
assert job.issues_snapshot == evidence and job.validation_status == "blocked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_database_write_is_409_without_losing_other_reviewer(review):
|
||||||
|
# Keep a genuinely stale ORM identity in a second transaction so the SQL
|
||||||
|
# version-column check, rather than just the request comparison, must fire.
|
||||||
|
with Session(review.engine) as stale_session:
|
||||||
|
stale = stale_session.get(CampaignVersion, "version-1")
|
||||||
|
assert stale is not None and stale.edit_revision == 1
|
||||||
|
_save(review, ids=(1,))
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
_save(review, ids=(2,), session=stale_session, base_revision=1)
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
review.session.expire_all()
|
||||||
|
assert review.version.editor_state["review_send"]["reviewed_message_keys"] == ["entry-1"]
|
||||||
|
assert len(review.audits) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delivery_final_lock_rejects_incremental_acceptance(review):
|
||||||
|
review.version.workflow_state = "completed"
|
||||||
|
review.session.commit()
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
_save(review)
|
||||||
|
assert error.value.status_code == 409
|
||||||
|
assert "review_send" not in review.version.editor_state
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_denial_rejects_before_persisting_review(review):
|
||||||
|
with patch.object(routes, "_get_campaign_for_principal", side_effect=HTTPException(status_code=403, detail="Owner access denied")):
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
_save(review)
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
assert "review_send" not in review.version.editor_state and review.audits == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_contract_requires_both_preconditions():
|
||||||
|
with pytest.raises(ValidationError, match="build_token and base_revision"):
|
||||||
|
CampaignReviewStateRequest(merge_progress=True, build_token="build-1")
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""Exercise real version routes/persistence without transport or filesystem effects."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend import route_support
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
assert_archive_encryption_allowed,
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||||
|
CampaignMailProfileBoundaryError,
|
||||||
|
assert_campaign_uses_mail_profile_reference,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign, CampaignIssue, CampaignVersion
|
||||||
|
from govoplan_campaign.backend.integrations import MailProfileError
|
||||||
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
|
CampaignPersistenceError,
|
||||||
|
build_campaign_version,
|
||||||
|
load_campaign_config_from_json,
|
||||||
|
validate_campaign_version,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignVersionDetailResponse, CampaignVersionUpdateRequest
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(*scopes: str) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1", membership_id="user-1", tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy() -> dict:
|
||||||
|
return {
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "campaign-1", "name": "Campaign", "mode": "send"},
|
||||||
|
"server": {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"smtp": {"host": "old.example.test", "password": "stored-secret"},
|
||||||
|
"imap": None,
|
||||||
|
"credentials": {},
|
||||||
|
"inherit_smtp_credentials": True,
|
||||||
|
},
|
||||||
|
"recipients": {"from": [{"email": "sender@example.test"}]},
|
||||||
|
"template": {"subject": "Subject", "text": "Body", "body_mode": "text"},
|
||||||
|
"entries": {"inline": []},
|
||||||
|
"attachments": {"zip": {"enabled": True, "archives": [{
|
||||||
|
"id": "archive-1", "method": "zip_standard", "password_enabled": True,
|
||||||
|
"password_delivery_channel": "separate_mail",
|
||||||
|
}]}},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repair():
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for name in ("access_users", "access_groups"):
|
||||||
|
if name not in Base.metadata.tables:
|
||||||
|
Table(name, Base.metadata, Column("id", String(36), primary_key=True))
|
||||||
|
Base.metadata.create_all(engine, tables=[
|
||||||
|
Base.metadata.tables["access_users"], Base.metadata.tables["access_groups"],
|
||||||
|
ChangeSequenceEntry.__table__, Campaign.__table__, CampaignVersion.__table__,
|
||||||
|
CampaignIssue.__table__,
|
||||||
|
])
|
||||||
|
with Session(engine) as session:
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-1", tenant_id="tenant-1", external_id="campaign-1",
|
||||||
|
name="Campaign", current_version_id="draft-1",
|
||||||
|
)
|
||||||
|
draft = CampaignVersion(id="draft-1", campaign_id=campaign.id, version_number=2, raw_json=_legacy())
|
||||||
|
history = CampaignVersion(id="history-1", campaign_id=campaign.id, version_number=1, raw_json=_legacy(), workflow_state="final")
|
||||||
|
session.add_all((campaign, draft, history))
|
||||||
|
session.commit()
|
||||||
|
audits: list[dict] = []
|
||||||
|
|
||||||
|
def audit(_session, _principal, **kwargs):
|
||||||
|
audits.append(kwargs)
|
||||||
|
if kwargs.get("commit"):
|
||||||
|
_session.commit()
|
||||||
|
|
||||||
|
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
|
||||||
|
with (
|
||||||
|
patch.object(route_support, "_get_campaign_for_principal", return_value=campaign),
|
||||||
|
patch.object(route_support, "audit_from_principal", side_effect=audit),
|
||||||
|
patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration),
|
||||||
|
patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None),
|
||||||
|
):
|
||||||
|
yield SimpleNamespace(session=session, campaign=campaign, draft=draft, history=history, integration=integration, audits=audits)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _public(repair) -> dict:
|
||||||
|
return CampaignVersionDetailResponse.model_validate(repair.draft).raw_json
|
||||||
|
|
||||||
|
|
||||||
|
def _save(repair, raw: dict, *, migrate: bool = False, principal=None):
|
||||||
|
return route_support._update_campaign_version_detail_response(
|
||||||
|
repair.session, principal or _principal("mail:profile:use"),
|
||||||
|
repair.campaign.id, repair.draft.id,
|
||||||
|
CampaignVersionUpdateRequest(
|
||||||
|
campaign_json=raw, base_revision=repair.draft.edit_revision,
|
||||||
|
migrate_legacy_mail_settings=migrate,
|
||||||
|
),
|
||||||
|
if_match=repair.draft.strong_etag, autosave=True,
|
||||||
|
audit_action="campaign.version_autosaved",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mail_first", [True, False])
|
||||||
|
def test_independent_repairs_persist_in_either_order_without_altering_history(repair, mail_first):
|
||||||
|
original_zip = copy.deepcopy(repair.draft.raw_json["attachments"]["zip"])
|
||||||
|
original_server = copy.deepcopy(repair.draft.raw_json["server"])
|
||||||
|
first = _public(repair)
|
||||||
|
if not mail_first:
|
||||||
|
first["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||||
|
result = _save(repair, first, migrate=mail_first)
|
||||||
|
repair.session.expire_all()
|
||||||
|
assert repair.draft.edit_revision == 2
|
||||||
|
assert result.mail_profile_migration_required is not mail_first
|
||||||
|
assert "stored-secret" not in repr(result)
|
||||||
|
assert repair.draft.build_summary is None
|
||||||
|
assert repair.draft.execution_snapshot is None
|
||||||
|
if mail_first:
|
||||||
|
assert repair.draft.raw_json["attachments"]["zip"] == original_zip
|
||||||
|
assert repair.draft.raw_json["server"] == {"mail_profile_id": "profile-1"}
|
||||||
|
# Real validation/build gates still reject the unresolved ZIP policy.
|
||||||
|
for action in (validate_campaign_version, build_campaign_version):
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.persistence.campaigns.load_version_config",
|
||||||
|
return_value=(repair.draft, None, object()),
|
||||||
|
), pytest.raises(CampaignPersistenceError, match="blocked"):
|
||||||
|
action(repair.session, tenant_id="tenant-1", version_id=repair.draft.id)
|
||||||
|
else:
|
||||||
|
assert repair.draft.raw_json["server"] == original_server
|
||||||
|
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
|
||||||
|
# The authoritative config loader still blocks legacy transport.
|
||||||
|
with pytest.raises(CampaignMailProfileBoundaryError, match="remove campaign-local"):
|
||||||
|
load_campaign_config_from_json(repair.session, tenant_id="tenant-1", raw_json=repair.draft.raw_json)
|
||||||
|
|
||||||
|
second = _public(repair)
|
||||||
|
if mail_first:
|
||||||
|
second["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||||
|
result = _save(repair, second, migrate=not mail_first)
|
||||||
|
repair.session.expire_all()
|
||||||
|
assert repair.draft.edit_revision == 3
|
||||||
|
assert not result.mail_profile_migration_required
|
||||||
|
assert_campaign_uses_mail_profile_reference(repair.draft.raw_json)
|
||||||
|
assert_archive_encryption_allowed(repair.session, repair.campaign, repair.draft.raw_json)
|
||||||
|
assert repair.history.raw_json == _legacy()
|
||||||
|
assert len(repair.audits) == 2
|
||||||
|
assert repair.audits[0]["details"]["legacy_mail_settings_preserved"] is not mail_first
|
||||||
|
assert repair.audits[0]["details"]["legacy_mail_settings_migrated"] is mail_first
|
||||||
|
assert not any(item["details"]["legacy_zipcrypto_acknowledgements"] for item in repair.audits)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zip_repair_does_not_require_use_of_unchanged_revoked_mail_profile(repair):
|
||||||
|
repair.integration.assert_campaign_mail_policy_allows_json.side_effect = MailProfileError("Profile revoked")
|
||||||
|
raw = _public(repair)
|
||||||
|
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||||
|
result = _save(repair, raw, principal=_principal())
|
||||||
|
assert result.mail_profile_migration_required
|
||||||
|
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
|
||||||
|
with pytest.raises(HTTPException) as denied:
|
||||||
|
_save(repair, _public(repair), migrate=True, principal=_principal())
|
||||||
|
assert denied.value.status_code == 403
|
||||||
|
assert "mail:profile:use" in denied.value.detail
|
||||||
|
with pytest.raises(HTTPException, match="Profile revoked"):
|
||||||
|
_save(repair, _public(repair), migrate=True)
|
||||||
|
assert repair.draft.mail_profile_migration_required
|
||||||
|
|
||||||
|
|
||||||
|
def test_archive_repair_is_saveable_after_mail_migration_and_a_stricter_credential_policy(repair):
|
||||||
|
_save(repair, _public(repair), migrate=True)
|
||||||
|
repair.integration.assert_campaign_mail_policy_allows_json.reset_mock()
|
||||||
|
repair.integration.assert_campaign_mail_policy_allows_json.side_effect = MailProfileError(
|
||||||
|
"SMTP credential policy requires an explicit credential selection"
|
||||||
|
)
|
||||||
|
raw = _public(repair)
|
||||||
|
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||||
|
result = _save(repair, raw, principal=_principal())
|
||||||
|
assert not result.mail_profile_migration_required
|
||||||
|
assert result.raw_json["server"] == {"mail_profile_id": "profile-1"}
|
||||||
|
assert result.raw_json["attachments"]["zip"]["archives"][0]["method"] == "aes"
|
||||||
|
repair.integration.assert_campaign_mail_policy_allows_json.assert_not_called()
|
||||||
|
changed_mail = _public(repair)
|
||||||
|
changed_mail["server"]["smtp_server_id"] = "smtp-1"
|
||||||
|
with pytest.raises(HTTPException) as missing_permission:
|
||||||
|
_save(repair, changed_mail, principal=_principal())
|
||||||
|
assert missing_permission.value.status_code == 403
|
||||||
|
with pytest.raises(HTTPException, match="explicit credential selection"):
|
||||||
|
_save(repair, changed_mail)
|
||||||
|
assert repair.draft.raw_json["server"] == {"mail_profile_id": "profile-1"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutation", ["new_profile", "remove_profile", "credential", "inline", "echo_inline"])
|
||||||
|
def test_archive_save_cannot_modify_or_introduce_mail_transport_without_migration(repair, mutation):
|
||||||
|
raw = _public(repair)
|
||||||
|
raw["attachments"]["zip"]["archives"][0]["method"] = "aes"
|
||||||
|
if mutation == "new_profile":
|
||||||
|
raw["server"]["mail_profile_id"] = "other-profile"
|
||||||
|
elif mutation == "remove_profile":
|
||||||
|
raw["server"] = {}
|
||||||
|
elif mutation == "credential":
|
||||||
|
raw["server"].update(smtp_server_id="smtp-1", smtp_credential_id="credential-1")
|
||||||
|
elif mutation == "inline":
|
||||||
|
raw["server"]["smtp"] = {"password": "injected-secret"}
|
||||||
|
else:
|
||||||
|
raw["server"] = copy.deepcopy(repair.draft.raw_json["server"])
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_save(repair, raw)
|
||||||
|
assert repair.draft.raw_json == _legacy()
|
||||||
|
assert repair.draft.edit_revision == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutation", ["acknowledgement", "reason", "actor", "method", "channel", "typed_boolean"])
|
||||||
|
def test_mail_migration_cannot_grandfather_modified_zip_settings(repair, mutation):
|
||||||
|
raw = _public(repair)
|
||||||
|
archive = raw["attachments"]["zip"]["archives"][0]
|
||||||
|
if mutation == "typed_boolean":
|
||||||
|
archive["password_enabled"] = 1 # Python True == 1 is not exact JSON equality.
|
||||||
|
elif mutation == "method":
|
||||||
|
archive["method"] = "unknown"
|
||||||
|
elif mutation == "channel":
|
||||||
|
archive["method"] = "aes"
|
||||||
|
archive["password_delivery_channel"] = "same_mail"
|
||||||
|
else:
|
||||||
|
archive[{
|
||||||
|
"acknowledgement": "legacy_zipcrypto_acknowledged",
|
||||||
|
"reason": "legacy_zipcrypto_reason",
|
||||||
|
"actor": "legacy_zipcrypto_acknowledged_by",
|
||||||
|
}[mutation]] = True if mutation == "acknowledgement" else "forged evidence"
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
_save(repair, raw, migrate=True)
|
||||||
|
assert repair.draft.raw_json == _legacy()
|
||||||
|
assert repair.draft.edit_revision == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_zip_is_not_restamped_even_after_legacy_permission_revocation(repair):
|
||||||
|
raw = _public(repair)
|
||||||
|
archive = raw["attachments"]["zip"]["archives"][0]
|
||||||
|
archive.update(
|
||||||
|
legacy_zipcrypto_acknowledged=True,
|
||||||
|
legacy_zipcrypto_reason="Existing recipient compatibility requirement",
|
||||||
|
legacy_zipcrypto_acknowledged_by="original-actor",
|
||||||
|
legacy_zipcrypto_acknowledged_at="2026-08-01T10:00:00+00:00",
|
||||||
|
)
|
||||||
|
repair.draft.raw_json = {**copy.deepcopy(repair.draft.raw_json), "attachments": copy.deepcopy(raw["attachments"])}
|
||||||
|
repair.session.commit()
|
||||||
|
result = _save(repair, raw, migrate=True, principal=_principal("mail:profile:use"))
|
||||||
|
assert result.raw_json["attachments"]["zip"]["archives"][0] == archive
|
||||||
|
assert repair.audits[-1]["details"]["legacy_zipcrypto_acknowledgements"] == []
|
||||||
|
with pytest.raises(CampaignArchiveEncryptionError, match="blocked"):
|
||||||
|
assert_archive_encryption_allowed(repair.session, repair.campaign, repair.draft.raw_json)
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.archive_encryption.effective_archive_encryption_policy",
|
||||||
|
return_value=SimpleNamespace(
|
||||||
|
available=True, allowed_password_encryption_methods={"aes", "zip_standard"},
|
||||||
|
allowed_password_delivery_channels={"separate_mail"}, reason="Policy allows legacy",
|
||||||
|
),
|
||||||
|
), pytest.raises(CampaignArchiveEncryptionError, match="Missing scope"):
|
||||||
|
assert_archive_encryption_allowed(
|
||||||
|
repair.session, repair.campaign, repair.draft.raw_json,
|
||||||
|
principal=_principal("mail:profile:use"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_missing_zip_remains_noop_without_policy_lookup(repair):
|
||||||
|
with patch("govoplan_campaign.backend.archive_encryption.effective_archive_encryption_policy") as policy:
|
||||||
|
candidate, evidence = stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
repair.session, repair.campaign, {}, {"template": {"text": "New"}}, principal=_principal(),
|
||||||
|
)
|
||||||
|
assert candidate == {"template": {"text": "New"}}
|
||||||
|
assert evidence == []
|
||||||
|
policy.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("target", ["individual", "global"])
|
||||||
|
def test_recipient_address_order_round_trips_through_the_real_save_route(repair, target):
|
||||||
|
addresses = [
|
||||||
|
{"name": "Zulu", "email": "zulu@example.test"},
|
||||||
|
{"name": "Alpha", "email": "alpha@example.test"},
|
||||||
|
{"name": "Beta", "email": "beta@example.test"},
|
||||||
|
]
|
||||||
|
raw = _public(repair)
|
||||||
|
if target == "individual":
|
||||||
|
raw["entries"]["inline"] = [{"id": "entry-1", "to": addresses}]
|
||||||
|
else:
|
||||||
|
raw["recipients"]["to"] = addresses
|
||||||
|
result = _save(repair, raw, principal=_principal("campaigns:recipient:write"))
|
||||||
|
repair.session.expire_all()
|
||||||
|
persisted = repair.session.get(CampaignVersion, "draft-1")
|
||||||
|
assert persisted is not None
|
||||||
|
if target == "individual":
|
||||||
|
assert persisted.raw_json["entries"]["inline"][0]["to"] == addresses
|
||||||
|
assert result.raw_json["entries"]["inline"][0]["to"] == addresses
|
||||||
|
else:
|
||||||
|
assert persisted.raw_json["recipients"]["to"] == addresses
|
||||||
|
assert result.raw_json["recipients"]["to"] == addresses
|
||||||
|
assert persisted.mail_profile_migration_required
|
||||||
|
assert persisted.edit_revision == 2
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -12,6 +14,8 @@ from govoplan_campaign.backend.services.job_queries import (
|
|||||||
_campaign_jobs_grid_filter_expressions,
|
_campaign_jobs_grid_filter_expressions,
|
||||||
_campaign_jobs_ordering,
|
_campaign_jobs_ordering,
|
||||||
_campaign_jobs_page_response,
|
_campaign_jobs_page_response,
|
||||||
|
_campaign_jobs_query_context,
|
||||||
|
_public_recipient_groups,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -119,6 +123,56 @@ class CampaignJobListQueryTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(raised.exception.status_code, 422)
|
self.assertEqual(raised.exception.status_code, 422)
|
||||||
|
|
||||||
|
def test_recipient_projection_preserves_each_group_order_without_extra_fields(self) -> None:
|
||||||
|
recipients = {"to": [{"email": "second@example.test", "name": "Second", "secret": "never-project"},
|
||||||
|
{"email": "first@example.test"}],
|
||||||
|
"cc": [{"email": "copy@example.test"}], "bcc": [{"email": "blind@example.test"}],
|
||||||
|
"from": {"email": "sender@example.test"}, "private": "never-project"}
|
||||||
|
result = _public_recipient_groups(recipients)
|
||||||
|
self.assertEqual(list(result), ["to", "cc", "bcc"])
|
||||||
|
self.assertEqual([value["email"] for value in result["to"]], ["second@example.test", "first@example.test"])
|
||||||
|
self.assertNotIn("never-project", str(result))
|
||||||
|
|
||||||
|
def test_each_frozen_recipient_group_is_searchable_before_pagination(self) -> None:
|
||||||
|
row = self.session.get(CampaignJob, "job-0")
|
||||||
|
row.resolved_recipients = {"to": [{"email": "second-to@example.test"}],
|
||||||
|
"cc": [{"email": "copy@example.test"}], "bcc": [{"email": "blind@example.test"}]}
|
||||||
|
self.session.commit()
|
||||||
|
for recipient in ("second-to", "copy@", "blind@"):
|
||||||
|
with self.subTest(recipient=recipient):
|
||||||
|
filters = _campaign_jobs_grid_filter_expressions({"recipient": recipient})
|
||||||
|
page = _campaign_jobs_page_response(self.session, campaign_id="campaign-1", version_id="version-1",
|
||||||
|
base_filters=[CampaignJob.tenant_id == "tenant-1"], filtered=filters,
|
||||||
|
reviewed_keys=set(), review_metadata={}, page=1, page_size=1, grid_filters={"recipient": recipient})
|
||||||
|
self.assertEqual(page.total, 1)
|
||||||
|
self.assertEqual(page.jobs[0]["id"], "job-0")
|
||||||
|
self.assertEqual(page.jobs[0]["resolved_recipients"]["bcc"], [{"email": "blind@example.test"}])
|
||||||
|
|
||||||
|
def test_free_search_also_matches_additional_recipients_and_keeps_tenant_scope(self) -> None:
|
||||||
|
row = self.session.get(CampaignJob, "job-0")
|
||||||
|
row.resolved_recipients = {"bcc": [{"email": "additional@example.test"}]}
|
||||||
|
other = self.session.get(CampaignJob, "job-1")
|
||||||
|
other.tenant_id = "other-tenant"
|
||||||
|
other.resolved_recipients = row.resolved_recipients
|
||||||
|
self.session.commit()
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1", has=lambda scope: scope == "campaigns:recipient:read")
|
||||||
|
with patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_principal"), \
|
||||||
|
patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_tenant", return_value=SimpleNamespace(id="campaign-1")), \
|
||||||
|
patch("govoplan_campaign.backend.services.job_queries._review_metadata", return_value=({}, set())):
|
||||||
|
_, _, filters, _, _ = _campaign_jobs_query_context(self.session, principal, campaign_id="campaign-1", version_id=None,
|
||||||
|
send_status=None, validation_status=None, imap_status=None, query_text="additional@example.test")
|
||||||
|
self.assertEqual([job.id for job in self.session.query(CampaignJob).filter(*filters)], ["job-0"])
|
||||||
|
|
||||||
|
def test_recipient_read_is_required_before_additional_addresses_are_queried(self) -> None:
|
||||||
|
principal = SimpleNamespace(tenant_id="tenant-1", has=lambda _scope: False)
|
||||||
|
with patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_principal"), \
|
||||||
|
patch("govoplan_campaign.backend.services.job_queries._get_campaign_for_tenant") as lookup:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
_campaign_jobs_query_context(self.session, principal, campaign_id="campaign-1", version_id=None,
|
||||||
|
send_status=None, validation_status=None, imap_status=None, query_text="blind@example.test")
|
||||||
|
self.assertEqual(raised.exception.status_code, 403)
|
||||||
|
lookup.assert_not_called()
|
||||||
|
|
||||||
def test_skipped_transport_filters_and_counts_remain_separate(self) -> None:
|
def test_skipped_transport_filters_and_counts_remain_separate(self) -> None:
|
||||||
base_filters = [CampaignJob.tenant_id == "tenant-1", CampaignJob.campaign_id == "campaign-1"]
|
base_filters = [CampaignJob.tenant_id == "tenant-1", CampaignJob.campaign_id == "campaign-1"]
|
||||||
grid_filters = {"send": 'list:["skipped"]', "imap": 'list:["skipped"]'}
|
grid_filters = {"send": 'list:["skipped"]', "imap": 'list:["skipped"]'}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import call, patch
|
from unittest.mock import Mock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_campaign.backend import route_support
|
from govoplan_campaign.backend import route_support
|
||||||
from govoplan_campaign.backend.routes import attachments as attachment_routes
|
from govoplan_campaign.backend.routes import attachments as attachment_routes
|
||||||
@@ -17,11 +19,14 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
|||||||
campaign_mail_profile_id,
|
campaign_mail_profile_id,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign, CampaignIssue, CampaignVersion
|
||||||
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError, load_campaign_config_from_json
|
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError, load_campaign_config_from_json
|
||||||
from govoplan_campaign.backend.persistence.versions import update_campaign_version
|
from govoplan_campaign.backend.persistence.versions import _updated_runtime_json, update_campaign_version
|
||||||
from govoplan_campaign.backend.integrations import MailCampaignIntegration
|
from govoplan_campaign.backend.integrations import MailCampaignIntegration, MailProfileError
|
||||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot, ensure_execution_snapshot
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot, ensure_execution_snapshot
|
||||||
from govoplan_campaign.backend.schemas import CampaignVersionUpdateRequest
|
from govoplan_campaign.backend.schemas import CampaignVersionUpdateRequest
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
def _campaign_json(server: dict[str, object] | None = None) -> dict[str, object]:
|
def _campaign_json(server: dict[str, object] | None = None) -> dict[str, object]:
|
||||||
@@ -237,6 +242,133 @@ def test_editing_a_legacy_record_requires_an_explicit_profile_migration() -> Non
|
|||||||
assert legacy_raw["server"]["smtp"]["password"] == "secret" # type: ignore[index]
|
assert legacy_raw["server"]["smtp"]["password"] == "secret" # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_legacy_migration_accepts_an_unchanged_authorized_profile_reference() -> None:
|
||||||
|
legacy = _campaign_json({
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"smtp": {"host": "smtp.example.test", "password": "legacy-secret"},
|
||||||
|
"imap": None,
|
||||||
|
"credentials": {},
|
||||||
|
"inherit_smtp_credentials": True,
|
||||||
|
"inherit_imap_credentials": True,
|
||||||
|
})
|
||||||
|
submitted = _campaign_json({"mail_profile_id": "profile-1"})
|
||||||
|
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
|
||||||
|
session = object()
|
||||||
|
with patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration):
|
||||||
|
result = _updated_runtime_json(
|
||||||
|
session, # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign=SimpleNamespace(id="campaign-1"),
|
||||||
|
version=SimpleNamespace(raw_json=legacy),
|
||||||
|
raw_json=submitted,
|
||||||
|
source_base_path=None,
|
||||||
|
migrate_legacy_mail_settings=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == submitted
|
||||||
|
assert result is not submitted
|
||||||
|
assert campaign_mail_profile_boundary_violations(result) == ()
|
||||||
|
assert "legacy-secret" not in repr(result)
|
||||||
|
assert legacy["server"]["smtp"]["password"] == "legacy-secret" # type: ignore[index]
|
||||||
|
integration.assert_campaign_mail_policy_allows_json.assert_called_once_with(
|
||||||
|
session, tenant_id="tenant-1", raw_json=result, campaign_id="campaign-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_legacy_migration_requires_a_profile_and_preserves_mail_authorization() -> None:
|
||||||
|
legacy = _campaign_json({"smtp": {"password": "legacy-secret"}})
|
||||||
|
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock(
|
||||||
|
side_effect=MailProfileError("Mail profile is not authorized for this campaign")
|
||||||
|
))
|
||||||
|
with patch("govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration):
|
||||||
|
for submitted, expected_error in (
|
||||||
|
(_campaign_json(), CampaignPersistenceError),
|
||||||
|
(_campaign_json({"mail_profile_id": "unauthorized-profile"}), MailProfileError),
|
||||||
|
(_campaign_json({"mail_profile_id": "profile-1", "smtp": {}}), CampaignMailProfileBoundaryError),
|
||||||
|
):
|
||||||
|
with pytest.raises(expected_error):
|
||||||
|
_updated_runtime_json(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign=SimpleNamespace(id="campaign-1"),
|
||||||
|
version=SimpleNamespace(raw_json=legacy),
|
||||||
|
raw_json=submitted,
|
||||||
|
source_base_path=None,
|
||||||
|
migrate_legacy_mail_settings=True,
|
||||||
|
)
|
||||||
|
assert integration.assert_campaign_mail_policy_allows_json.call_count == 1
|
||||||
|
assert legacy["server"]["smtp"]["password"] == "legacy-secret" # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def test_persisted_legacy_draft_can_migrate_then_save_other_content_without_changing_history() -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for table_name in ("access_users", "access_groups"):
|
||||||
|
if table_name not in Base.metadata.tables:
|
||||||
|
Table(table_name, Base.metadata, Column("id", String(36), primary_key=True))
|
||||||
|
Base.metadata.create_all(engine, tables=[
|
||||||
|
Base.metadata.tables["access_users"],
|
||||||
|
Base.metadata.tables["access_groups"],
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
Campaign.__table__,
|
||||||
|
CampaignVersion.__table__,
|
||||||
|
CampaignIssue.__table__,
|
||||||
|
])
|
||||||
|
legacy = _campaign_json({
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"smtp": {"host": "old.example.test", "password": "historical-secret"},
|
||||||
|
"imap": None,
|
||||||
|
"credentials": {},
|
||||||
|
"inherit_smtp_credentials": True,
|
||||||
|
"inherit_imap_credentials": True,
|
||||||
|
})
|
||||||
|
integration = SimpleNamespace(assert_campaign_mail_policy_allows_json=Mock())
|
||||||
|
try:
|
||||||
|
with Session(engine) as session, patch(
|
||||||
|
"govoplan_campaign.backend.persistence.versions.mail_integration", return_value=integration
|
||||||
|
):
|
||||||
|
campaign = Campaign(id="campaign-1", tenant_id="tenant-1", external_id="campaign-1", name="Campaign", current_version_id="draft-1")
|
||||||
|
historical = CampaignVersion(id="history-1", campaign_id=campaign.id, version_number=1, raw_json=legacy, workflow_state="final")
|
||||||
|
draft = CampaignVersion(id="draft-1", campaign_id=campaign.id, version_number=2, raw_json=legacy, editor_state={"created_from": "minimal_campaign"})
|
||||||
|
session.add_all((campaign, historical, draft))
|
||||||
|
session.commit()
|
||||||
|
original_revision = draft.edit_revision
|
||||||
|
assert draft.mail_profile_migration_required
|
||||||
|
|
||||||
|
migrated = update_campaign_version(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1", campaign_id=campaign.id, version_id=draft.id,
|
||||||
|
raw_json=_campaign_json({"mail_profile_id": "profile-1"}),
|
||||||
|
editor_state={"created_from": "minimal_campaign"},
|
||||||
|
expected_revision=original_revision,
|
||||||
|
migrate_legacy_mail_settings=True,
|
||||||
|
autosave=True,
|
||||||
|
)
|
||||||
|
session.expire_all()
|
||||||
|
persisted = session.get(CampaignVersion, migrated.id)
|
||||||
|
assert persisted is not None
|
||||||
|
assert not persisted.mail_profile_migration_required
|
||||||
|
assert persisted.raw_json["server"] == {"mail_profile_id": "profile-1"}
|
||||||
|
assert persisted.edit_revision > original_revision
|
||||||
|
assert persisted.autosaved_at is not None
|
||||||
|
assert "historical-secret" not in repr(persisted.raw_json)
|
||||||
|
assert session.get(CampaignVersion, historical.id).raw_json == legacy
|
||||||
|
|
||||||
|
edited = _campaign_json({"mail_profile_id": "profile-1"})
|
||||||
|
edited["template"]["subject"] = "Edited after migration" # type: ignore[index]
|
||||||
|
updated = update_campaign_version(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1", campaign_id=campaign.id, version_id=persisted.id,
|
||||||
|
raw_json=edited,
|
||||||
|
expected_revision=persisted.edit_revision,
|
||||||
|
)
|
||||||
|
session.expire_all()
|
||||||
|
assert session.get(CampaignVersion, updated.id).raw_json["template"]["subject"] == "Edited after migration"
|
||||||
|
assert session.get(CampaignVersion, historical.id).raw_json == legacy
|
||||||
|
assert integration.assert_campaign_mail_policy_allows_json.call_count == 1
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
|
def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
|
||||||
principal = SimpleNamespace(
|
principal = SimpleNamespace(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""Mock accepted exceptions using real frozen jobs/EML, never new live effects."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
from email import policy
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from test_incremental_review_persistence import review, _save
|
||||||
|
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||||
|
from govoplan_campaign.backend.db.models import CampaignJob
|
||||||
|
from govoplan_campaign.backend.dev import mock_campaign
|
||||||
|
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError
|
||||||
|
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, create_execution_snapshot
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def frozen(review, tmp_path):
|
||||||
|
review.version.raw_json = {**review.version.raw_json, "server": {"mail_profile_id": "profile-1"}}
|
||||||
|
jobs = review.session.query(CampaignJob).order_by(CampaignJob.entry_index).all()
|
||||||
|
for job in jobs:
|
||||||
|
job.subject = f"Frozen subject {job.entry_index}"
|
||||||
|
job.resolved_recipients = {"from": {"email": "sender@example.test"}, "to": [{"email": f"recipient-{job.entry_index}@example.test"}]}
|
||||||
|
job.issues_snapshot = [{**item, "severity": "warning", "message": "Explicit attachment exception"} for item in job.issues_snapshot]
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = "sender@example.test"
|
||||||
|
message["To"] = f"recipient-{job.entry_index}@example.test"
|
||||||
|
message["Subject"] = job.subject
|
||||||
|
message["Date"] = "Mon, 07 Sep 2026 09:00:00 +0200"
|
||||||
|
message["Message-ID"] = f"<frozen-{job.entry_index}@example.test>"
|
||||||
|
message.set_content(f"Frozen reviewed body {job.entry_index}")
|
||||||
|
payload = message.as_bytes(policy=policy.SMTP)
|
||||||
|
path = tmp_path / f"message-{job.entry_index}.eml"
|
||||||
|
path.write_bytes(payload)
|
||||||
|
job.eml_local_path = str(path)
|
||||||
|
job.eml_size_bytes = len(payload)
|
||||||
|
job.eml_sha256 = hashlib.sha256(payload).hexdigest()
|
||||||
|
job.message_id_header = str(message["Message-ID"])
|
||||||
|
review.session.flush()
|
||||||
|
payload, digest = create_execution_snapshot(review.version, mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="smtp-1", imap_transport_revision="imap-1", delivery=DeliveryConfig(),
|
||||||
|
jobs=jobs, build_summary=review.version.build_summary)
|
||||||
|
review.version.execution_snapshot = payload
|
||||||
|
review.version.execution_snapshot_hash = digest
|
||||||
|
review.session.commit()
|
||||||
|
_save(review, ids=(1, 2), complete=True)
|
||||||
|
mailbox = Mock()
|
||||||
|
mailbox.consume_fail_next_smtp.return_value = False
|
||||||
|
mailbox.consume_fail_next_imap.return_value = False
|
||||||
|
mailbox.get_failures.return_value = {}
|
||||||
|
mailbox.record_smtp_delivery.return_value = SimpleNamespace(id="mock-1")
|
||||||
|
mailbox.record_imap_append.return_value = SimpleNamespace(id="mock-imap-1")
|
||||||
|
mailbox.list_records.return_value = []
|
||||||
|
with (
|
||||||
|
patch.object(mock_campaign, "_mock_mailbox", return_value=mailbox),
|
||||||
|
patch("govoplan_campaign.backend.sending.execution.files_integration", return_value=SimpleNamespace(available=False)),
|
||||||
|
patch("govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed", return_value=SimpleNamespace(policy_hash="archive-policy")),
|
||||||
|
patch("govoplan_campaign.backend.sending.execution.profile_delivery_summary", return_value={"smtp_transport_revision": "smtp-1", "imap_transport_revision": "imap-1"}) as transport,
|
||||||
|
):
|
||||||
|
yield SimpleNamespace(**vars(review), jobs=jobs, mailbox=mailbox, transport=transport)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(frozen, **options):
|
||||||
|
return mock_campaign.run_mock_campaign_send(frozen.session, tenant_id="tenant-1", campaign_id="campaign-1", version_id="version-1",
|
||||||
|
use_reviewed_build=True, include_needs_review=False, send=True, **options)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mock_reuses_reviewed_frozen_bytes_and_decisions_without_reasking_or_mutating_campaign(frozen):
|
||||||
|
original = copy.deepcopy(frozen.version.editor_state)
|
||||||
|
revision = frozen.version.edit_revision
|
||||||
|
with patch.object(mock_campaign, "_build_mock_campaign_run", side_effect=AssertionError("Do not rebuild reviewed messages")):
|
||||||
|
result = _run(frozen)
|
||||||
|
assert result["send"]["sent_count"] == 2 and result["send"]["skipped_count"] == 0
|
||||||
|
assert result["use_reviewed_build"] is True and result["build"]["review_satisfied"] is True
|
||||||
|
assert [step["status"] for step in result["steps"]] == ["ok", "ok", "ok", "ok"]
|
||||||
|
assert frozen.mailbox.record_smtp_delivery.call_count == 2
|
||||||
|
captured = frozen.mailbox.record_smtp_delivery.call_args_list[0].args[0]
|
||||||
|
assert str(captured["Message-ID"]) == "<frozen-1@example.test>"
|
||||||
|
assert "Frozen reviewed body 1" in captured.get_content()
|
||||||
|
frozen.session.expire_all()
|
||||||
|
assert frozen.version.editor_state == original and frozen.version.edit_revision == revision
|
||||||
|
assert all(job.send_status == "not_queued" for job in frozen.jobs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mutation", ["partial", "build", "issue", "reason_evidence", "configuration", "snapshot", "bytes", "transport", "imap"])
|
||||||
|
def test_invalid_frozen_review_stops_before_mock_capture_or_mailbox_clear(frozen, mutation):
|
||||||
|
if mutation == "partial":
|
||||||
|
state = copy.deepcopy(frozen.version.editor_state)
|
||||||
|
state["review_send"]["inspection_complete"] = False
|
||||||
|
frozen.version.editor_state = state
|
||||||
|
elif mutation == "build":
|
||||||
|
frozen.version.build_summary = {"build_token": "different-build", "built_count": 2}
|
||||||
|
elif mutation == "issue":
|
||||||
|
frozen.jobs[1].issues_snapshot = [{"code": "new-hard-block", "source": "attachments", "behavior": "block", "severity": "error", "message": "Missing required attachment"}]
|
||||||
|
elif mutation == "reason_evidence":
|
||||||
|
state = copy.deepcopy(frozen.version.editor_state)
|
||||||
|
state["review_send"]["issue_decisions"][0]["issue_fingerprint"] = "f" * 64
|
||||||
|
frozen.version.editor_state = state
|
||||||
|
elif mutation == "configuration":
|
||||||
|
frozen.version.raw_json = {**frozen.version.raw_json, "template": {"subject": "Unreviewed changed subject"}}
|
||||||
|
elif mutation == "snapshot":
|
||||||
|
frozen.version.execution_snapshot = None
|
||||||
|
elif mutation == "bytes":
|
||||||
|
from pathlib import Path
|
||||||
|
Path(frozen.jobs[1].eml_local_path).write_bytes(b"changed bytes")
|
||||||
|
else:
|
||||||
|
frozen.transport.return_value = {"smtp_transport_revision": "changed" if mutation == "transport" else "smtp-1", "imap_transport_revision": "changed" if mutation == "imap" else "imap-1"}
|
||||||
|
frozen.session.flush()
|
||||||
|
with pytest.raises((mock_campaign.MockCampaignSendError, ExecutionSnapshotError, CampaignPersistenceError)):
|
||||||
|
_run(frozen, clear_mailbox=True)
|
||||||
|
frozen.mailbox.record_smtp_delivery.assert_not_called()
|
||||||
|
frozen.mailbox.record_imap_append.assert_not_called()
|
||||||
|
frozen.mailbox.clear_records.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_dropped_frozen_message_stays_skipped_even_with_ask_evidence(frozen):
|
||||||
|
excluded = frozen.jobs[1]
|
||||||
|
excluded.validation_status = "excluded"
|
||||||
|
excluded.send_status = "skipped"
|
||||||
|
excluded.imap_status = "skipped"
|
||||||
|
payload, digest = create_execution_snapshot(frozen.version, mail_profile_id="profile-1", smtp_transport_revision="smtp-1",
|
||||||
|
imap_transport_revision="imap-1", delivery=DeliveryConfig(), jobs=frozen.jobs, build_summary=frozen.version.build_summary)
|
||||||
|
frozen.version.execution_snapshot = payload
|
||||||
|
frozen.version.execution_snapshot_hash = digest
|
||||||
|
state = copy.deepcopy(frozen.version.editor_state)
|
||||||
|
state["review_send"]["reviewed_message_keys"] = ["entry-1"]
|
||||||
|
state["review_send"]["issue_decisions"] = state["review_send"]["issue_decisions"][:1]
|
||||||
|
frozen.version.editor_state = state
|
||||||
|
frozen.session.commit()
|
||||||
|
result = _run(frozen)
|
||||||
|
assert result["send"]["sent_count"] == 1 and result["send"]["skipped_count"] == 1
|
||||||
|
assert frozen.mailbox.record_smtp_delivery.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_authoring_mock_still_uses_transient_preview(frozen):
|
||||||
|
with patch.object(mock_campaign, "_build_mock_campaign_run", side_effect=RuntimeError("authoring preview path")):
|
||||||
|
with pytest.raises(RuntimeError, match="authoring preview path"):
|
||||||
|
mock_campaign.run_mock_campaign_send(frozen.session, tenant_id="tenant-1", campaign_id="campaign-1", send=False)
|
||||||
@@ -8,6 +8,7 @@ from govoplan_campaign.backend.routes.assignments import router as assignments_r
|
|||||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||||
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
||||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||||
|
from govoplan_campaign.backend.routes.delivery_settings import router as delivery_settings_router
|
||||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||||
@@ -27,6 +28,7 @@ def _operation_keys(candidate_router) -> list[tuple[str, str]]:
|
|||||||
|
|
||||||
def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||||
workflow_routers = (
|
workflow_routers = (
|
||||||
|
delivery_settings_router,
|
||||||
operations_router,
|
operations_router,
|
||||||
transfers_router,
|
transfers_router,
|
||||||
campaigns_router,
|
campaigns_router,
|
||||||
@@ -48,12 +50,16 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
|||||||
actual = _operation_keys(router)
|
actual = _operation_keys(router)
|
||||||
|
|
||||||
assert actual == expected
|
assert actual == expected
|
||||||
assert len(actual) == 96
|
assert len(actual) == 100
|
||||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||||
|
|
||||||
|
|
||||||
def test_key_routes_are_owned_by_their_focused_router() -> None:
|
def test_key_routes_are_owned_by_their_focused_router() -> None:
|
||||||
expectations = (
|
expectations = (
|
||||||
|
(delivery_settings_router, ("GET", "/campaigns/settings/delivery-policy/{scope}")),
|
||||||
|
(delivery_settings_router, ("PUT", "/campaigns/settings/delivery-policy/{scope}")),
|
||||||
|
(delivery_router, ("GET", "/campaigns/{campaign_id}/delivery-progress")),
|
||||||
|
(delivery_router, ("POST", "/campaigns/{campaign_id}/jobs/{job_id}/recover-claim")),
|
||||||
(
|
(
|
||||||
operations_router,
|
operations_router,
|
||||||
("POST", "/campaigns/operations/artifacts/reconcile"),
|
("POST", "/campaigns/operations/artifacts/reconcile"),
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
|
|||||||
"source": "deployment_default",
|
"source": "deployment_default",
|
||||||
"deployment_max_recipient_jobs": 25,
|
"deployment_max_recipient_jobs": 25,
|
||||||
"tenant_max_recipient_jobs": None,
|
"tenant_max_recipient_jobs": None,
|
||||||
|
"system_max_recipient_jobs": 200,
|
||||||
|
"deployment_ceiling_explicit": False,
|
||||||
"provider_diagnostic": "provider-secret-policy",
|
"provider_diagnostic": "provider-secret-policy",
|
||||||
},
|
},
|
||||||
results=[
|
results=[
|
||||||
@@ -107,6 +109,8 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
|
|||||||
"source": "deployment_default",
|
"source": "deployment_default",
|
||||||
"deployment_max_recipient_jobs": 25,
|
"deployment_max_recipient_jobs": 25,
|
||||||
"tenant_max_recipient_jobs": None,
|
"tenant_max_recipient_jobs": None,
|
||||||
|
"system_max_recipient_jobs": 200,
|
||||||
|
"deployment_ceiling_explicit": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
audit_details = audit.call_args.kwargs["details"]
|
audit_details = audit.call_args.kwargs["details"]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from govoplan_campaign.backend import router as campaign_api
|
from govoplan_campaign.backend import router as campaign_api
|
||||||
|
from govoplan_core.tenancy.scope import Tenant
|
||||||
from govoplan_campaign.backend.routes import delivery as router
|
from govoplan_campaign.backend.routes import delivery as router
|
||||||
from govoplan_campaign.backend.delivery_policy import (
|
from govoplan_campaign.backend.delivery_policy import (
|
||||||
CampaignDeliveryPolicyError,
|
CampaignDeliveryPolicyError,
|
||||||
@@ -20,8 +21,14 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobSendStatus,
|
JobSendStatus,
|
||||||
JobValidationStatus,
|
JobValidationStatus,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.integrations import (
|
||||||
|
MailProfileError,
|
||||||
|
SmtpConfigurationError,
|
||||||
|
SmtpSendError,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.sending.jobs import (
|
from govoplan_campaign.backend.sending.jobs import (
|
||||||
QueueCampaignResult,
|
QueueCampaignResult,
|
||||||
|
SendJobError,
|
||||||
SynchronousSendRejected,
|
SynchronousSendRejected,
|
||||||
_ensure_synchronous_send_count_allowed,
|
_ensure_synchronous_send_count_allowed,
|
||||||
_pause_jobs_after_systemic_smtp_failure,
|
_pause_jobs_after_systemic_smtp_failure,
|
||||||
@@ -39,7 +46,7 @@ class _PolicySession:
|
|||||||
self.tenant = SimpleNamespace(settings=settings or {})
|
self.tenant = SimpleNamespace(settings=settings or {})
|
||||||
|
|
||||||
def get(self, _model, _id):
|
def get(self, _model, _id):
|
||||||
return self.tenant
|
return self.tenant if _model is Tenant else None
|
||||||
|
|
||||||
|
|
||||||
def _version() -> SimpleNamespace:
|
def _version() -> SimpleNamespace:
|
||||||
@@ -218,6 +225,65 @@ def test_post_queue_growth_is_rejected_before_batch_or_provider_preflight() -> N
|
|||||||
batch_preflight.assert_not_called()
|
batch_preflight.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("error", "reason", "message", "fails_on_exit"),
|
||||||
|
[
|
||||||
|
(MailProfileError("private credential detail"), "mail_profile_preflight_failed", "credential selection", False),
|
||||||
|
(SmtpConfigurationError("private credential detail"), "smtp_configuration_preflight_failed", "SMTP configuration", False),
|
||||||
|
(SmtpSendError("private credential detail", reason_code="smtp_authentication_failed"), "smtp_authentication_failed", "SMTP authentication failed", False),
|
||||||
|
(SmtpSendError("private credential detail", reason_code="smtp_preflight_rejected"), "smtp_preflight_rejected", "server rejected", False),
|
||||||
|
(SmtpSendError("private credential detail", reason_code="private provider code"), "smtp_connectivity_unavailable", "connectivity", False),
|
||||||
|
(OSError("private credential detail"), "smtp_connectivity_unavailable", "connectivity", False),
|
||||||
|
(OSError("private credential detail"), None, "Messages may already have been sent", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_smtp_preflight_errors_distinguish_policy_configuration_and_connection_without_secrets(
|
||||||
|
error: Exception, reason: str | None, message: str, fails_on_exit: bool,
|
||||||
|
) -> None:
|
||||||
|
session = Mock()
|
||||||
|
campaign = SimpleNamespace(id="campaign-1")
|
||||||
|
job = _job("one", queue_status="queued", send_status="queued")
|
||||||
|
policy = effective_synchronous_send_policy(_PolicySession(), tenant_id="tenant-1", environ={})
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def rejected_connection():
|
||||||
|
if fails_on_exit:
|
||||||
|
yield SimpleNamespace(connection_count=1, reconnect_count=0)
|
||||||
|
raise error
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._get_current_version", return_value=_version()),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._ensure_version_validated_and_locked"),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._ensure_campaign_execution_snapshot"),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.effective_synchronous_send_policy", return_value=policy),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_queue", return_value=[job]),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.queue_campaign_jobs") as queue,
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_version", return_value=[job]),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._preflight_synchronous_send_batch", return_value={"one": Mock()}),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._synchronous_smtp_batch_manager", return_value=rejected_connection()),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._deliver_job_with_recovery", return_value=SimpleNamespace(status="smtp_accepted", as_dict=lambda: {"status": "smtp_accepted"})) as deliver,
|
||||||
|
pytest.raises(SendJobError if fails_on_exit else SynchronousSendRejected) as rejected,
|
||||||
|
):
|
||||||
|
send_campaign_now(session, tenant_id="tenant-1", campaign_id=campaign.id)
|
||||||
|
|
||||||
|
assert message in str(rejected.value)
|
||||||
|
assert "private" not in str(rejected.value)
|
||||||
|
assert queue.call_args.kwargs["commit_queue"] is False
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
|
if fails_on_exit:
|
||||||
|
assert "no message was sent" not in str(rejected.value)
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
deliver.assert_called_once()
|
||||||
|
else:
|
||||||
|
assert rejected.value.reason == reason
|
||||||
|
assert "no message was sent" in str(rejected.value)
|
||||||
|
assert "private" not in str(rejected.value.audit_details())
|
||||||
|
assert rejected.value.eligible_count == 1
|
||||||
|
session.commit.assert_not_called()
|
||||||
|
deliver.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("workers_available", "expected_mode", "expected_enqueued"),
|
("workers_available", "expected_mode", "expected_enqueued"),
|
||||||
((False, "database_queue", 0), (True, "worker_queue", 1)),
|
((False, "database_queue", 0), (True, "worker_queue", 1)),
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Disposable SQLite and in-process HTTP only; never run Docker or a mail provider."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion, SendAttempt
|
||||||
|
from govoplan_campaign.backend.routes import delivery as routes
|
||||||
|
from govoplan_campaign.backend.sending import jobs
|
||||||
|
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.recovery import RecoveryOperation, verify_recovery_evidence_chain
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
|
||||||
|
from test_mail_testbed_acceptance import runner, _settings, _Response, FIXTURE_PATH
|
||||||
|
from test_workerless_recovery import recovery, _stale_claim # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fixture_claim(recovery, tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "govoplan-campaign-greenmail-regression"
|
||||||
|
root.mkdir()
|
||||||
|
database_file = root / "acceptance.db"
|
||||||
|
with recovery.engine.connect() as source, sqlite3.connect(database_file) as target:
|
||||||
|
source.connection.driver_connection.backup(target)
|
||||||
|
engine = create_engine(f"sqlite:///{database_file}")
|
||||||
|
factory = sessionmaker(engine)
|
||||||
|
session = factory()
|
||||||
|
fixture = SimpleNamespace(
|
||||||
|
session=session, factory=factory, engine=engine, root=root,
|
||||||
|
campaign=session.get(Campaign, "campaign"), version=session.get(CampaignVersion, "version"),
|
||||||
|
snapshot=recovery.snapshot, provider=recovery.provider,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(jobs, "get_database", lambda: SimpleNamespace(SessionLocal=factory))
|
||||||
|
job, lease, node, operation_id = _stale_claim(fixture, expired=False, owner="active")
|
||||||
|
node.metadata_ = {"acceptance_worker_pid": 12345}
|
||||||
|
session.commit()
|
||||||
|
fixture.job, fixture.lease, fixture.node, fixture.operation_id = job, lease, node, operation_id
|
||||||
|
fixture.process = SimpleNamespace(pid=12345, returncode=-9, poll=lambda: -9)
|
||||||
|
monkeypatch.setenv("APP_ENV", "test")
|
||||||
|
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{database_file}")
|
||||||
|
monkeypatch.setattr(runner.tempfile, "gettempdir", lambda: str(tmp_path))
|
||||||
|
yield fixture
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _recover(fixture, client):
|
||||||
|
return runner.recover_stopped_fixture_claim(
|
||||||
|
client, {}, database=SimpleNamespace(engine=fixture.engine, SessionLocal=fixture.factory),
|
||||||
|
runtime_root=fixture.root, campaign_id="campaign", version_id="version", stopped_process=fixture.process,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_proof_calls_actual_fenced_http_action_without_replaying_smtp(fixture_claim, monkeypatch):
|
||||||
|
fixture = fixture_claim
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes.router, prefix="/api/v1")
|
||||||
|
actor = SimpleNamespace(id="operator")
|
||||||
|
scopes = frozenset({"campaigns:campaign:reconcile", "campaigns:recipient:read"})
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: ApiPrincipal(
|
||||||
|
principal=PrincipalRef(account_id="operator", membership_id="operator", tenant_id="tenant", scopes=scopes),
|
||||||
|
user=actor, account=actor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def session_dependency():
|
||||||
|
with fixture.factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = session_dependency
|
||||||
|
monkeypatch.setattr(routes, "_get_campaign_for_principal", lambda session, *_args, **_kwargs: session.get(Campaign, "campaign"))
|
||||||
|
monkeypatch.setattr(routes, "audit_from_principal", lambda session, *_args, **_kwargs: session.commit())
|
||||||
|
with TestClient(app) as client:
|
||||||
|
evidence = _recover(fixture, client)
|
||||||
|
assert evidence == {"stopped_process_verified": True, "fixture_lease_expired": True, "explicit_fenced_recovery": True}
|
||||||
|
fixture.session.expire_all()
|
||||||
|
assert fixture.job.send_status == "outcome_unknown"
|
||||||
|
assert fixture.job.claim_token is None
|
||||||
|
assert fixture.session.query(SendAttempt).one().status == "outcome_unknown"
|
||||||
|
assert fixture.session.query(SendAttempt).one().finished_at is not None
|
||||||
|
assert fixture.session.get(RecoveryOperation, fixture.operation_id).status == "outcome_unknown"
|
||||||
|
assert verify_recovery_evidence_chain(fixture.session, fixture.operation_id)
|
||||||
|
assert fixture.lease.fencing_token > 1
|
||||||
|
fixture.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("unsafe", ["production", "foreign_database", "live_process", "wrong_pid", "different_incarnation"])
|
||||||
|
def test_fixture_claim_proof_rejects_unsafe_target_or_process_without_state_changes(fixture_claim, monkeypatch, unsafe):
|
||||||
|
fixture = fixture_claim
|
||||||
|
if unsafe == "production":
|
||||||
|
monkeypatch.setenv("APP_ENV", "production")
|
||||||
|
elif unsafe == "foreign_database":
|
||||||
|
monkeypatch.setenv("DATABASE_URL", "sqlite:////tmp/a-different-database.db")
|
||||||
|
elif unsafe == "live_process":
|
||||||
|
fixture.process = SimpleNamespace(pid=12345, returncode=None, poll=lambda: None)
|
||||||
|
elif unsafe == "wrong_pid":
|
||||||
|
fixture.process = SimpleNamespace(pid=22222, returncode=-9, poll=lambda: -9)
|
||||||
|
else:
|
||||||
|
fixture.node.incarnation = "new-worker-incarnation"
|
||||||
|
fixture.session.commit()
|
||||||
|
original_expiry = fixture.lease.expires_at
|
||||||
|
client = Mock()
|
||||||
|
with pytest.raises(runner.AcceptanceError):
|
||||||
|
_recover(fixture, client)
|
||||||
|
client.post.assert_not_called()
|
||||||
|
fixture.session.expire_all()
|
||||||
|
assert fixture.job.send_status == "sending"
|
||||||
|
assert fixture.job.claim_token == "stale-token"
|
||||||
|
assert fixture.node.state == "active"
|
||||||
|
assert fixture.lease.expires_at == original_expiry
|
||||||
|
assert fixture.session.get(RecoveryOperation, fixture.operation_id).status == "running"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("duplicate_mutates", [False, True])
|
||||||
|
def test_process_restart_requires_unchanged_claim_before_explicit_recovery(monkeypatch, duplicate_mutates):
|
||||||
|
interrupted = {"job_count": 1, "send_status_counts": {"sending": 1}, "attempt_status_counts": {"smtp_in_progress": 1}, "unfinished_attempt_count": 1}
|
||||||
|
recovered = {"job_count": 1, "send_status_counts": {"outcome_unknown": 1}, "attempt_status_counts": {"outcome_unknown": 1}, "unfinished_attempt_count": 0}
|
||||||
|
states = iter([interrupted, recovered if duplicate_mutates else interrupted, recovered])
|
||||||
|
first, second = Mock(), Mock()
|
||||||
|
first.poll.return_value = -9
|
||||||
|
workers = iter([first, second])
|
||||||
|
prepared = SimpleNamespace(campaign_id="campaign", version_id="version", public_evidence=lambda: {})
|
||||||
|
monkeypatch.setattr(runner, "prepare_campaign_scenario", lambda *_args, **_kwargs: prepared)
|
||||||
|
monkeypatch.setattr(runner, "_start_campaign_worker_task", lambda *_args: next(workers))
|
||||||
|
monkeypatch.setattr(runner, "_terminate_worker_process", lambda *_args: None)
|
||||||
|
monkeypatch.setattr(runner, "_wait_for_worker_process", lambda *_args, **_kwargs: None)
|
||||||
|
client = Mock()
|
||||||
|
client.post.return_value = _Response(200, {
|
||||||
|
"queued_count": 1, "skipped_count": 0, "blocked_count": 0, "enqueued_count": 0,
|
||||||
|
"delivery_mode": "database_queue", "worker_queue_available": False, "dry_run": False,
|
||||||
|
})
|
||||||
|
client.get.return_value = _Response(200, {"cards": {}, "status_counts": {"send": {"outcome_unknown": 1}, "imap": {}}})
|
||||||
|
endpoint = Mock()
|
||||||
|
endpoint.wait_for_data.return_value = True
|
||||||
|
endpoint.evidence.return_value = {"connection_count": 1, "accepted_rcpt_commands": 1, "refused_rcpt_commands": 0, "data_transactions": 1}
|
||||||
|
recover_claim = Mock(return_value={"explicit_fenced_recovery": True})
|
||||||
|
arguments = dict(
|
||||||
|
fixture_path=FIXTURE_PATH, profile_id="profile", settings=_settings(), endpoint=endpoint,
|
||||||
|
snapshot_probe=lambda _: ({}, {}), audit_probe=lambda *_: {"campaign.created": 1, "campaign.validated": 1, "campaign.messages_built": 1, "campaign.queued": 1},
|
||||||
|
delivery_probe=lambda *_: next(states), worker_job_probe=lambda _: "job", recover_claim=recover_claim,
|
||||||
|
)
|
||||||
|
if duplicate_mutates:
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="without stopped-runtime proof"):
|
||||||
|
runner.execute_worker_interruption_scenario(client, {}, **arguments)
|
||||||
|
recover_claim.assert_not_called()
|
||||||
|
else:
|
||||||
|
evidence = runner.execute_worker_interruption_scenario(client, {}, **arguments)
|
||||||
|
assert evidence["interrupted_durable_state"] == evidence["restarted_durable_state"] == interrupted
|
||||||
|
assert evidence["recovered_durable_state"] == recovered
|
||||||
|
recover_claim.assert_called_once_with("campaign", "version", first)
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_task_bootstrap_registers_unique_fixture_owner_and_accepts_read_only_duplicate(monkeypatch):
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from govoplan_core import celery_app, db
|
||||||
|
from govoplan_core.core import runtime_coordination
|
||||||
|
|
||||||
|
identity = SimpleNamespace(node_id="fixture-owner")
|
||||||
|
bind = Mock(return_value=identity)
|
||||||
|
register = Mock()
|
||||||
|
session = MagicMock()
|
||||||
|
database = SimpleNamespace(SessionLocal=MagicMock())
|
||||||
|
database.SessionLocal.return_value.__enter__.return_value = session
|
||||||
|
task = SimpleNamespace(run=Mock(return_value={"status": "already_sending"}))
|
||||||
|
monkeypatch.setattr(celery_app, "_worker_runtime_identity", bind)
|
||||||
|
monkeypatch.setattr(celery_app, "send_email", task)
|
||||||
|
monkeypatch.setattr(runtime_coordination, "register_runtime_node", register)
|
||||||
|
monkeypatch.setattr(db.session, "get_database", lambda: database)
|
||||||
|
monkeypatch.setattr(sys, "argv", ["fixture-worker", "fixture-job"])
|
||||||
|
exec(compile(runner.WORKER_TASK_CODE, "<isolated-worker-test>", "exec"), {})
|
||||||
|
assert bind.call_args.args[0].hostname == f"campaign-acceptance-{os.getpid()}"
|
||||||
|
register.assert_called_once_with(session, identity, metadata={"acceptance_worker_pid": os.getpid()})
|
||||||
|
session.commit.assert_called_once()
|
||||||
|
task.run.assert_called_once_with("fixture-job")
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
"""Workerless recovery operates the real job/attempt ledger, never resend actions."""
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine, event
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign, CampaignVersion, CampaignJob, SendAttempt, ImapAppendAttempt,
|
||||||
|
PostboxDeliveryAttempt, PrintOutputAttempt, CampaignMessageAction,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.delivery_policy import SynchronousSendPolicy
|
||||||
|
from govoplan_campaign.backend.integrations import MailProfileError, SmtpSendError, ImapAppendError
|
||||||
|
from govoplan_campaign.backend.routes import delivery as routes
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignRetryJobsRequest, CampaignSendUnattemptedRequest, CampaignRecoverClaimRequest
|
||||||
|
from govoplan_campaign.backend.services.delivery_progress import campaign_delivery_progress
|
||||||
|
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata, recover_stale_delivery_claim, RecoveryStateConflict
|
||||||
|
from govoplan_campaign.backend.sending import jobs
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.recovery import RecoveryOperation, RecoveryCheckpoint, verify_recovery_evidence_chain
|
||||||
|
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeNode, process_runtime_identity
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
from govoplan_core.auth import get_api_principal, ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _SmtpResult:
|
||||||
|
accepted_count: int = 1
|
||||||
|
refused_recipients: dict = field(default_factory=dict)
|
||||||
|
envelope_recipients: list = field(default_factory=lambda: ["recipient@example.test"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recovery(tmp_path, monkeypatch):
|
||||||
|
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'recovery.db'}")
|
||||||
|
for name in ("access_users", "access_groups"):
|
||||||
|
if name not in Base.metadata.tables:
|
||||||
|
Table(name, Base.metadata, Column("id", String(36), primary_key=True))
|
||||||
|
Base.metadata.create_all(engine, tables=[Base.metadata.tables[name] for name in ("access_users", "access_groups")] + [
|
||||||
|
model.__table__ for model in (ChangeSequenceEntry, Campaign, CampaignVersion, CampaignJob,
|
||||||
|
SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt, PrintOutputAttempt, CampaignMessageAction,
|
||||||
|
DistributedLease, RuntimeNode, RecoveryOperation, RecoveryCheckpoint)
|
||||||
|
])
|
||||||
|
factory = sessionmaker(engine)
|
||||||
|
session = factory()
|
||||||
|
campaign = Campaign(id="campaign", tenant_id="tenant", external_id="C", name="Recovery", current_version_id="version")
|
||||||
|
version = CampaignVersion(id="version", campaign_id="campaign", version_number=1, raw_json={},
|
||||||
|
locked_at=datetime.now(timezone.utc), validation_summary={"ok": True},
|
||||||
|
build_summary={"build_token": "build"}, execution_snapshot_hash="f" * 64,
|
||||||
|
editor_state={"review_send": {"build_token": "build", "inspection_complete": True, "reviewed_message_keys": ["reviewed"]}})
|
||||||
|
session.add_all([campaign, version])
|
||||||
|
session.commit()
|
||||||
|
snapshot = SimpleNamespace(
|
||||||
|
mail_profile_id="profile", smtp_server_id="smtp", smtp_credential_id="credential",
|
||||||
|
smtp_transport_revision="smtp-revision", imap_transport_revision="imap-revision", uses_mail=True,
|
||||||
|
delivery=SimpleNamespace(retry=SimpleNamespace(max_attempts=3),
|
||||||
|
rate_limit=SimpleNamespace(messages_per_minute=60), imap_append_sent=SimpleNamespace(enabled=True)),
|
||||||
|
)
|
||||||
|
provider = Mock()
|
||||||
|
provider.wait_for_rate_limit.return_value = None
|
||||||
|
provider.send_campaign_email_bytes.return_value = _SmtpResult()
|
||||||
|
provider.campaign_imap_batch.side_effect = lambda **_: nullcontext(SimpleNamespace(connection_count=1, reconnect_count=0))
|
||||||
|
monkeypatch.setattr(jobs, "get_database", lambda: SimpleNamespace(SessionLocal=factory))
|
||||||
|
monkeypatch.setattr(jobs, "ensure_execution_snapshot", lambda *_args, **_kw: snapshot)
|
||||||
|
monkeypatch.setattr(jobs, "_ensure_campaign_approval_gate", Mock())
|
||||||
|
monkeypatch.setattr(jobs, "_emit_campaign_status_notification", Mock())
|
||||||
|
monkeypatch.setattr(jobs, "_mark_accepted_job_artifacts", Mock())
|
||||||
|
monkeypatch.setattr(jobs, "mail_integration", lambda: provider)
|
||||||
|
monkeypatch.setattr(jobs, "_celery_enabled", lambda: False)
|
||||||
|
monkeypatch.setattr(jobs, "effective_synchronous_send_policy", lambda *_args, **_kw: SynchronousSendPolicy(2, "system", 500, system_max_recipient_jobs=2))
|
||||||
|
monkeypatch.setattr(jobs, "_synchronous_smtp_batch_manager", lambda *_args, **_kw: nullcontext(SimpleNamespace(connection_count=1, reconnect_count=0)))
|
||||||
|
monkeypatch.setattr(jobs, "_send_job_delivery_context", lambda _session, job: SimpleNamespace(
|
||||||
|
version=version, snapshot=snapshot, message_bytes=b"immutable message",
|
||||||
|
envelope_from="sender@example.test", envelope_recipients=["recipient@example.test"],
|
||||||
|
))
|
||||||
|
monkeypatch.setattr(jobs, "profile_delivery_summary", lambda *_: {"smtp_transport_revision": "smtp-revision"})
|
||||||
|
yield SimpleNamespace(session=session, factory=factory, engine=engine, campaign=campaign, version=version, provider=provider, snapshot=snapshot)
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _add(recovery, name, *, status="not_queued", attempt=0, **kwargs):
|
||||||
|
job = CampaignJob(id=name, tenant_id="tenant", campaign_id="campaign", campaign_version_id="version",
|
||||||
|
entry_index=recovery.session.query(CampaignJob).count() + 1, entry_id=name,
|
||||||
|
build_status="built", validation_status="ready", send_status=status,
|
||||||
|
queue_status="draft", attempt_count=attempt, eml_sha256="e" * 64,
|
||||||
|
eml_local_path="unused-exact-message.eml",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}, "to": [{"email": "recipient@example.test"}]})
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(job, key, value)
|
||||||
|
recovery.session.add(job)
|
||||||
|
recovery.session.commit()
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _retry(recovery, **kw):
|
||||||
|
return jobs.queue_failed_jobs_for_retry(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="version", enqueue_celery=False, run_inline=True, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def _continue(recovery, **kw):
|
||||||
|
return jobs.queue_unattempted_jobs(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="version", enqueue_celery=False, run_inline=True, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inline_retry_records_canonical_acceptance_and_never_creates_resend_action(recovery):
|
||||||
|
failed = _add(recovery, "failed", status="failed_temporary", attempt=1)
|
||||||
|
recovery.session.add(SendAttempt(job_id=failed.id, attempt_number=1, status="failed_temporary", finished_at=datetime.now(timezone.utc)))
|
||||||
|
recovery.session.commit()
|
||||||
|
result = _retry(recovery)
|
||||||
|
assert result["sent_count"] == result["attempted_count"] == 1
|
||||||
|
with recovery.factory() as check:
|
||||||
|
current = check.get(CampaignJob, failed.id)
|
||||||
|
assert current.send_status == "smtp_accepted" and current.attempt_count == 2 and current.imap_status == "pending"
|
||||||
|
assert [a.status for a in check.query(SendAttempt).order_by(SendAttempt.attempt_number)] == ["failed_temporary", "smtp_accepted"]
|
||||||
|
assert check.query(CampaignMessageAction).count() == 0
|
||||||
|
ledger = check.query(RecoveryOperation).one()
|
||||||
|
assert ledger.status == "succeeded" and verify_recovery_evidence_chain(check, ledger.id)
|
||||||
|
second = _retry(recovery)
|
||||||
|
assert second["selected_count"] == 0 and recovery.provider.send_campaign_email_bytes.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_continue_is_bounded_and_skips_accepted_excluded_unknown_and_active(recovery):
|
||||||
|
for name in ("one", "two", "three"):
|
||||||
|
_add(recovery, name)
|
||||||
|
_add(recovery, "accepted", status="smtp_accepted", attempt=1)
|
||||||
|
_add(recovery, "excluded", status="skipped", validation_status="excluded")
|
||||||
|
_add(recovery, "unknown", status="outcome_unknown", attempt=1)
|
||||||
|
_add(recovery, "active", status="sending", attempt=1, claim_token="live")
|
||||||
|
_add(recovery, "claimed", status="claimed", claim_token="live-before-smtp")
|
||||||
|
first = _continue(recovery)
|
||||||
|
assert first["selected_count"] == first["sent_count"] == 2 and first["remaining_count"] == 1
|
||||||
|
assert recovery.session.get(CampaignJob, "three").send_status == "not_queued"
|
||||||
|
second = _continue(recovery)
|
||||||
|
assert second["sent_count"] == 1 and second["remaining_count"] == 0
|
||||||
|
assert recovery.provider.send_campaign_email_bytes.call_count == 3
|
||||||
|
assert recovery.session.get(CampaignJob, "active").send_status == "sending"
|
||||||
|
assert recovery.session.get(CampaignJob, "claimed").send_status == "claimed"
|
||||||
|
assert recovery.session.get(CampaignJob, "unknown").send_status == "outcome_unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_continue_drains_queued_unattempted_remainder_but_never_repeats_prior_print_effect(recovery):
|
||||||
|
_add(recovery, "queued", status="queued", queue_status="queued")
|
||||||
|
_add(recovery, "printed", status="queued", queue_status="queued", print_attempt_count=1)
|
||||||
|
result = _continue(recovery)
|
||||||
|
assert result["sent_count"] == 1
|
||||||
|
assert recovery.session.get(CampaignJob, "queued").send_status == "smtp_accepted"
|
||||||
|
assert recovery.session.get(CampaignJob, "printed").send_status == "queued"
|
||||||
|
assert recovery.provider.send_campaign_email_bytes.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unattempted_review_exception_requires_completed_same_build_review(recovery):
|
||||||
|
_add(recovery, "reviewed", validation_status="needs_review")
|
||||||
|
_add(recovery, "not-reviewed", validation_status="needs_review")
|
||||||
|
result = _continue(recovery)
|
||||||
|
assert result["selected_count"] == 1 and result["sent_count"] == 1
|
||||||
|
assert recovery.session.get(CampaignJob, "not-reviewed").send_status == "not_queued"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("reason", ["max_attempts", "permanent", "blocked", "approval", "limit_zero"])
|
||||||
|
def test_recovery_preserves_delivery_gates(recovery, monkeypatch, reason):
|
||||||
|
job = _add(recovery, "candidate", status="failed_temporary", attempt=1)
|
||||||
|
if reason == "max_attempts":
|
||||||
|
job.attempt_count = 3
|
||||||
|
elif reason == "permanent":
|
||||||
|
job.send_status = "failed_permanent"
|
||||||
|
elif reason == "blocked":
|
||||||
|
job.validation_status = "blocked"
|
||||||
|
elif reason == "approval":
|
||||||
|
monkeypatch.setattr(jobs, "_ensure_campaign_approval_gate", Mock(side_effect=jobs.QueueingError("Approval missing")))
|
||||||
|
else:
|
||||||
|
monkeypatch.setattr(jobs, "effective_synchronous_send_policy", lambda *_args, **_kw: SynchronousSendPolicy(0, "system", 500))
|
||||||
|
recovery.session.commit()
|
||||||
|
if reason in {"approval", "limit_zero"}:
|
||||||
|
with pytest.raises(jobs.QueueingError):
|
||||||
|
_retry(recovery)
|
||||||
|
else:
|
||||||
|
assert _retry(recovery)["selected_count"] == 0
|
||||||
|
assert recovery.provider.send_campaign_email_bytes.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_preflight_failure_rolls_back_queue_changes_without_provider_effect(recovery, monkeypatch):
|
||||||
|
job = _add(recovery, "failed", status="failed_temporary", attempt=1)
|
||||||
|
class Refuse:
|
||||||
|
def __enter__(self):
|
||||||
|
raise MailProfileError("Revoked profile selection")
|
||||||
|
def __exit__(self, *_):
|
||||||
|
return False
|
||||||
|
monkeypatch.setattr(jobs, "_synchronous_smtp_batch_manager", lambda *_args, **_kw: Refuse())
|
||||||
|
with pytest.raises(jobs.SynchronousSendRejected):
|
||||||
|
_retry(recovery)
|
||||||
|
recovery.session.expire_all()
|
||||||
|
assert job.send_status == "failed_temporary" and job.attempt_count == 1
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_worker_task_does_not_mutate_a_live_smtp_claim(recovery):
|
||||||
|
job = _add(recovery, "live", status="sending", attempt=1, claim_token="live", queue_status="sending")
|
||||||
|
result = jobs.send_campaign_job(recovery.session, job_id=job.id)
|
||||||
|
assert result.status == "already_sending"
|
||||||
|
recovery.session.expire_all()
|
||||||
|
assert job.send_status == "sending" and job.claim_token == "live"
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_counts_include_active_and_partition_each_channel_without_loading_jobs(recovery):
|
||||||
|
_add(recovery, "accepted", status="smtp_accepted", imap_status="appended")
|
||||||
|
_add(recovery, "imap-active", status="smtp_accepted", imap_status="appending")
|
||||||
|
_add(recovery, "sending", status="sending", queue_status="sending", imap_status="pending")
|
||||||
|
_add(recovery, "claimed", status="claimed", imap_status="pending")
|
||||||
|
_add(recovery, "pending", imap_status="pending")
|
||||||
|
_add(recovery, "failed", status="failed_temporary", imap_status="failed")
|
||||||
|
_add(recovery, "unknown", status="outcome_unknown", imap_status="outcome_unknown")
|
||||||
|
_add(recovery, "paused", status="queued", queue_status="paused", imap_status="pending")
|
||||||
|
_add(recovery, "cancelled", status="cancelled", imap_status="skipped")
|
||||||
|
_add(recovery, "excluded", status="skipped", validation_status="excluded", imap_status="skipped")
|
||||||
|
recovery.session.expunge_all()
|
||||||
|
loaded = []
|
||||||
|
event.listen(recovery.session, "loaded_as_persistent", lambda _session, row: loaded.append(row))
|
||||||
|
progress = campaign_delivery_progress(recovery.session, tenant_id="tenant", campaign_id="campaign")
|
||||||
|
assert progress["total_jobs"] == 10
|
||||||
|
assert progress["smtp"] == dict(total=9, processed=5, accepted=2, active=2, pending=1, failed=1, outcome_unknown=1, excluded=1, paused=1, cancelled=1)
|
||||||
|
assert progress["imap"] == dict(total=8, processed=3, appended=1, active=1, pending=4, failed=1, outcome_unknown=1, excluded=2)
|
||||||
|
assert not any(isinstance(row, CampaignJob) for row in loaded)
|
||||||
|
assert progress["status_counts"]["send"]["claimed"] == 1
|
||||||
|
assert "recipient@example.test" not in repr(progress)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_scopes_version_and_preserves_partial_multichannel_smtp_acceptance(recovery):
|
||||||
|
job = _add(recovery, "partial", status="partially_accepted", delivery_channel_policy="mail_and_postbox")
|
||||||
|
recovery.session.add(SendAttempt(job_id=job.id, attempt_number=1, status="smtp_accepted"))
|
||||||
|
recovery.session.add(CampaignVersion(id="old", campaign_id="campaign", version_number=2, raw_json={}))
|
||||||
|
recovery.session.commit()
|
||||||
|
result = campaign_delivery_progress(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="version")
|
||||||
|
assert result["smtp"]["accepted"] == 1 and result["smtp"]["failed"] == 0
|
||||||
|
assert campaign_delivery_progress(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="old")["total_jobs"] == 0
|
||||||
|
with pytest.raises(jobs.QueueingError):
|
||||||
|
campaign_delivery_progress(recovery.session, tenant_id="other", campaign_id="campaign")
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_processes_only_selected_version_and_uses_one_lazy_batch(recovery, monkeypatch):
|
||||||
|
first = _add(recovery, "current", status="smtp_accepted", imap_status="pending")
|
||||||
|
recovery.session.add(CampaignVersion(id="old", campaign_id="campaign", version_number=2, raw_json={}))
|
||||||
|
recovery.session.commit()
|
||||||
|
old = _add(recovery, "old-job", status="smtp_accepted", imap_status="pending", campaign_version_id="old")
|
||||||
|
appended = []
|
||||||
|
def append(session, *, job_id, dry_run):
|
||||||
|
appended.append(job_id)
|
||||||
|
return jobs.AppendSentResult(job_id=job_id, status="appended", attempt_number=1)
|
||||||
|
monkeypatch.setattr(jobs, "append_sent_for_job", append)
|
||||||
|
result = jobs.enqueue_pending_imap_appends(recovery.session, tenant_id="tenant", campaign_id="campaign", enqueue_celery=False, run_inline=True)
|
||||||
|
assert appended == [first.id] and result["version_id"] == "version" and result["imap_connection_count"] == 1
|
||||||
|
jobs.enqueue_pending_imap_appends(recovery.session, tenant_id="tenant", campaign_id="campaign", version_id="old", enqueue_celery=False, run_inline=True)
|
||||||
|
assert appended == [first.id, old.id]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("outcome", ["failed", "outcome_unknown", "raised_unknown"])
|
||||||
|
def test_inline_imap_summary_keeps_failed_and_unknown_results_distinct(recovery, monkeypatch, outcome):
|
||||||
|
job = _add(recovery, "imap", status="smtp_accepted", imap_status="pending")
|
||||||
|
def append(*_args, **_kwargs):
|
||||||
|
if outcome == "raised_unknown":
|
||||||
|
raise ImapAppendError("Provider response lost", outcome_unknown=True)
|
||||||
|
return jobs.AppendSentResult(job_id=job.id, status=outcome, attempt_number=1)
|
||||||
|
monkeypatch.setattr(jobs, "append_sent_for_job", append)
|
||||||
|
result = jobs.enqueue_pending_imap_appends(recovery.session, tenant_id="tenant", campaign_id="campaign", run_inline=True)
|
||||||
|
assert result["appended_count"] == 0 and result["processed_count"] == 1
|
||||||
|
assert result["outcome_unknown_count"] == int(outcome != "failed")
|
||||||
|
assert result["failed_count"] == int(outcome == "failed")
|
||||||
|
assert result["results"][0]["status"] == ("outcome_unknown" if outcome == "raised_unknown" else outcome)
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_claim(recovery, *, channel="smtp", expired=True, owner="stopped", policy="mail"):
|
||||||
|
job = _add(recovery, "stale", status="sending" if channel == "smtp" else "smtp_accepted", queue_status="sending" if channel == "smtp" else "draft",
|
||||||
|
attempt=1, claim_token="stale-token" if channel == "smtp" else None,
|
||||||
|
imap_status="appending" if channel == "imap" else "pending", imap_claim_token="stale-token" if channel == "imap" else None,
|
||||||
|
delivery_channel_policy=policy)
|
||||||
|
identity = process_runtime_identity()
|
||||||
|
context = SimpleNamespace(version=recovery.version, snapshot=recovery.snapshot, folder="Sent")
|
||||||
|
begin = jobs._begin_job_delivery_recovery if channel == "smtp" else jobs._begin_imap_append_recovery
|
||||||
|
started = begin(job=job, context=context, claim_token="stale-token")
|
||||||
|
recovery.session.expire_all()
|
||||||
|
lease = recovery.session.query(DistributedLease).one()
|
||||||
|
lease.holder_node_id = "old-process"
|
||||||
|
lease.holder_incarnation = "old-incarnation"
|
||||||
|
lease.expires_at = datetime.now(timezone.utc) + timedelta(minutes=-1 if expired else 10)
|
||||||
|
node = RuntimeNode(installation_id=identity.installation_id, node_id="old-process",
|
||||||
|
incarnation="replacement" if owner == "replaced" else "old-incarnation", role="worker", software_version="test", composition_hash="c" * 64,
|
||||||
|
state="stopped" if owner == "stopped" else "active")
|
||||||
|
recovery.session.add(node)
|
||||||
|
attempt = SendAttempt(job_id=job.id, attempt_number=1, status="smtp_in_progress", claim_token="stale-token") if channel == "smtp" else ImapAppendAttempt(job_id=job.id, attempt_number=1, status="appending", claim_token="stale-token")
|
||||||
|
recovery.session.add(attempt)
|
||||||
|
recovery.session.commit()
|
||||||
|
return job, lease, node, started.operation_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("channel", ["smtp", "imap"])
|
||||||
|
@pytest.mark.parametrize("owner", ["stopped", "replaced"])
|
||||||
|
def test_stale_claim_recovery_is_fenced_unknown_then_explicit_evidence_reconciliation(recovery, channel, owner):
|
||||||
|
job, lease, node, operation_id = _stale_claim(recovery, channel=channel, owner=owner)
|
||||||
|
metadata = job_recovery_metadata(recovery.session, [job])[job.id][channel]
|
||||||
|
assert metadata["eligible"] is True
|
||||||
|
result = recover_stale_delivery_claim(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id,
|
||||||
|
channel=channel, expected_revision=metadata["revision"], note="Verified original worker process stopped; inspect provider evidence next.")
|
||||||
|
recovery.session.commit()
|
||||||
|
assert result["reconciliation_required"] is True
|
||||||
|
assert (job.send_status if channel == "smtp" else job.imap_status) == "outcome_unknown"
|
||||||
|
assert recovery.session.get(RecoveryOperation, operation_id).status == "outcome_unknown"
|
||||||
|
assert verify_recovery_evidence_chain(recovery.session, operation_id)
|
||||||
|
assert _retry(recovery)["selected_count"] == 0
|
||||||
|
decision = "not_sent" if channel == "smtp" else "imap_not_appended"
|
||||||
|
jobs.reconcile_job_outcome(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, decision=decision, note="Provider logs and target mailbox confirm the effect did not occur.")
|
||||||
|
assert (job.send_status if channel == "smtp" else job.imap_status) == ("failed_temporary" if channel == "smtp" else "failed")
|
||||||
|
assert recovery.session.get(RecoveryOperation, operation_id).status == "recovered"
|
||||||
|
assert verify_recovery_evidence_chain(recovery.session, operation_id)
|
||||||
|
original_attempt = recovery.session.query(SendAttempt if channel == "smtp" else ImapAppendAttempt).filter_by(job_id=job.id).one()
|
||||||
|
assert original_attempt.status == ("reconciled_not_sent" if channel == "smtp" else "reconciled_imap_not_appended")
|
||||||
|
assert "Provider logs" in original_attempt.error_message
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("case", ["live_lease", "active_owner", "changed_revision", "wrong_tenant", "missing_ledger"])
|
||||||
|
def test_claim_recovery_rejects_live_ambiguous_changed_or_unauthorized_state(recovery, case):
|
||||||
|
job, lease, node, operation_id = _stale_claim(recovery, expired=case != "live_lease", owner="active" if case == "active_owner" else "stopped")
|
||||||
|
metadata = job_recovery_metadata(recovery.session, [job])[job.id]["smtp"]
|
||||||
|
if case == "missing_ledger":
|
||||||
|
recovery.session.get(RecoveryOperation, operation_id).idempotency_key = "another-operation"
|
||||||
|
recovery.session.commit()
|
||||||
|
with pytest.raises(jobs.QueueingError):
|
||||||
|
recover_stale_delivery_claim(recovery.session, tenant_id="other" if case == "wrong_tenant" else "tenant", campaign_id="campaign", job_id=job.id, channel="smtp", expected_revision="f" * 64 if case == "changed_revision" else metadata["revision"], note="Evidence")
|
||||||
|
recovery.session.rollback()
|
||||||
|
assert job.send_status == "sending" and job.claim_token == "stale-token"
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class _Principal:
|
||||||
|
tenant_id = "tenant"
|
||||||
|
user = SimpleNamespace(id="operator")
|
||||||
|
|
||||||
|
def __init__(self, *scopes):
|
||||||
|
self.scopes = set(scopes)
|
||||||
|
|
||||||
|
def has(self, scope):
|
||||||
|
return scope in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["retry", "unattempted"])
|
||||||
|
@pytest.mark.parametrize("missing", ["send", "recipient"])
|
||||||
|
def test_inline_recovery_requires_send_and_recipient_permission(recovery, kind, missing):
|
||||||
|
principal = _Principal(*({"campaigns:campaign:send", "campaigns:recipient:read"} - {"campaigns:campaign:send" if missing == "send" else "campaigns:recipient:read"}))
|
||||||
|
endpoint = routes.retry_campaign_jobs if kind == "retry" else routes.send_unattempted_campaign_jobs
|
||||||
|
schema = CampaignRetryJobsRequest if kind == "retry" else CampaignSendUnattemptedRequest
|
||||||
|
with patch.object(routes, "_get_campaign_for_principal", return_value=recovery.campaign):
|
||||||
|
with pytest.raises(HTTPException) as error:
|
||||||
|
endpoint("campaign", schema(version_id="version", run_inline=True), session=recovery.session, principal=principal)
|
||||||
|
assert error.value.status_code == 403
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_claim_recovery_and_audit_are_atomic(recovery):
|
||||||
|
job, lease, node, operation_id = _stale_claim(recovery)
|
||||||
|
metadata = job_recovery_metadata(recovery.session, [job])[job.id]["smtp"]
|
||||||
|
original_fence = lease.fencing_token
|
||||||
|
principal = _Principal("campaigns:recipient:read", "campaigns:campaign:reconcile")
|
||||||
|
with patch.object(routes, "_get_campaign_for_principal", return_value=recovery.campaign), patch.object(routes, "audit_from_principal", autospec=True, side_effect=RuntimeError("Audit unavailable")):
|
||||||
|
with pytest.raises(RuntimeError, match="Audit unavailable"):
|
||||||
|
routes.recover_campaign_job_claim("campaign", job.id, CampaignRecoverClaimRequest(channel="smtp", expected_revision=metadata["revision"], note="Stopped process verified"), session=recovery.session, principal=principal)
|
||||||
|
with recovery.factory() as check:
|
||||||
|
assert check.get(CampaignJob, job.id).send_status == "sending"
|
||||||
|
assert check.get(CampaignJob, job.id).claim_token == "stale-token"
|
||||||
|
assert check.get(RecoveryOperation, operation_id).status == "running"
|
||||||
|
assert check.get(DistributedLease, lease.id).fencing_token == original_fence
|
||||||
|
assert verify_recovery_evidence_chain(check, operation_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_worker_queue_path_remains_supported(recovery, monkeypatch):
|
||||||
|
job = _add(recovery, "retry", status="failed_temporary", attempt=1)
|
||||||
|
enqueue = Mock()
|
||||||
|
monkeypatch.setattr(jobs, "_celery_enabled", lambda: True)
|
||||||
|
monkeypatch.setattr(jobs, "_celery_enqueue_send_job", enqueue)
|
||||||
|
result = jobs.queue_failed_jobs_for_retry(recovery.session, tenant_id="tenant", campaign_id="campaign", enqueue_celery=True)
|
||||||
|
assert result["enqueued_count"] == 1 and result["run_inline"] is False
|
||||||
|
enqueue.assert_called_once_with(job.id)
|
||||||
|
assert job.send_status == "queued" and job.attempt_count == 1
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_dry_run_and_foreign_ids_never_change_selected_state(recovery):
|
||||||
|
job = _add(recovery, "retry", status="failed_temporary", attempt=1)
|
||||||
|
assert _retry(recovery, dry_run=True)["selected_count"] == 1
|
||||||
|
assert job.send_status == "failed_temporary" and job.attempt_count == 1
|
||||||
|
assert _retry(recovery, job_ids=["foreign-job"])["selected_count"] == 0
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_recovery_public_response_never_returns_provider_diagnostics():
|
||||||
|
projected = routes._public_recovery_result({"run_inline": True, "campaign_id": "campaign", "version_id": "version",
|
||||||
|
"selected_count": 1, "remaining_count": 0, "sent_count": 0, "failed_count": 1,
|
||||||
|
"results": [{"job_id": "job", "status": "failed", "message": "Provider rejected hidden@example.test"}]})
|
||||||
|
assert projected["selected_count"] == 1
|
||||||
|
assert projected["results"] == [{"job_id": "job", "status": "failed"}]
|
||||||
|
assert "hidden@example.test" not in repr(projected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("result_status", ["failed_temporary", "failed_permanent", "outcome_unknown"])
|
||||||
|
def test_inline_summary_counts_returned_failures_and_unknown_separately(recovery, monkeypatch, result_status):
|
||||||
|
job = _add(recovery, "candidate", status="failed_temporary", attempt=1)
|
||||||
|
monkeypatch.setattr(jobs, "_deliver_job_with_recovery", lambda *_args, **_kw: jobs.SendJobResult(job_id=job.id, status=result_status, attempt_number=2))
|
||||||
|
result = _retry(recovery)
|
||||||
|
assert result["failed_count"] == int(result_status != "outcome_unknown")
|
||||||
|
assert result["outcome_unknown_count"] == int(result_status == "outcome_unknown")
|
||||||
|
assert result["skipped_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path,scope,method,payload", [
|
||||||
|
("delivery-progress?version_id=version", "campaigns:campaign:read", "get", None),
|
||||||
|
("jobs/retry", "campaigns:campaign:retry", "post", {"version_id": "version", "dry_run": True}),
|
||||||
|
("jobs/send-unattempted", "campaigns:campaign:queue", "post", {"version_id": "version", "dry_run": True}),
|
||||||
|
("jobs/job/recover-claim", "campaigns:campaign:reconcile", "post", {"channel": "smtp", "expected_revision": "f" * 64, "note": "Evidence"}),
|
||||||
|
])
|
||||||
|
def test_http_scope_dependencies_reject_missing_route_permission(recovery, path, scope, method, payload):
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(routes.router, prefix="/api/v1")
|
||||||
|
scopes = {"campaigns:campaign:send", "campaigns:recipient:read"}
|
||||||
|
actor = SimpleNamespace(id="operator")
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: ApiPrincipal(principal=PrincipalRef(account_id="operator", membership_id="operator", tenant_id="tenant", scopes=frozenset(scopes)), user=actor, account=actor)
|
||||||
|
app.dependency_overrides[get_session] = lambda: recovery.session
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.request(method, f"/api/v1/campaigns/campaign/{path}", **({"json": payload} if payload else {}))
|
||||||
|
assert response.status_code == 403
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("channel", ["smtp", "imap"])
|
||||||
|
def test_accepted_reconciliation_updates_only_matching_original_campaign_operation(recovery, channel):
|
||||||
|
job, lease, node, operation_id = _stale_claim(recovery, channel=channel)
|
||||||
|
meta = job_recovery_metadata(recovery.session, [job])[job.id][channel]
|
||||||
|
recover_stale_delivery_claim(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, channel=channel, expected_revision=meta["revision"], note="Stopped process verified")
|
||||||
|
recovery.session.commit()
|
||||||
|
jobs.reconcile_job_outcome(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id,
|
||||||
|
decision="smtp_accepted" if channel == "smtp" else "imap_appended", note="Provider log confirms the exact Message-ID was accepted")
|
||||||
|
with recovery.factory() as check:
|
||||||
|
assert check.get(RecoveryOperation, operation_id).status == "succeeded"
|
||||||
|
assert verify_recovery_evidence_chain(check, operation_id)
|
||||||
|
assert check.query(RecoveryOperation).count() == 1
|
||||||
|
assert (check.get(CampaignJob, job.id).send_status if channel == "smtp" else check.get(CampaignJob, job.id).imap_status) == ("smtp_accepted" if channel == "smtp" else "appended")
|
||||||
|
recovery.provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("channel", ["smtp", "imap"])
|
||||||
|
def test_reconciliation_audit_failure_rolls_back_campaign_attempt_and_core_operation(recovery, channel):
|
||||||
|
job, lease, node, operation_id = _stale_claim(recovery, channel=channel)
|
||||||
|
meta = job_recovery_metadata(recovery.session, [job])[job.id][channel]
|
||||||
|
recover_stale_delivery_claim(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, channel=channel, expected_revision=meta["revision"], note="Stopped process verified")
|
||||||
|
recovery.session.commit()
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignResolveOutcomeRequest
|
||||||
|
with patch.object(routes, "_get_campaign_for_principal", return_value=recovery.campaign), patch.object(routes, "audit_from_principal", autospec=True, side_effect=RuntimeError("Audit unavailable")):
|
||||||
|
with pytest.raises(RuntimeError, match="Audit unavailable"):
|
||||||
|
routes.resolve_campaign_job_outcome("campaign", job.id, CampaignResolveOutcomeRequest(decision="not_sent" if channel == "smtp" else "imap_not_appended", note="Verified effect absent"), session=recovery.session, principal=_Principal("campaigns:recipient:read"))
|
||||||
|
with recovery.factory() as check:
|
||||||
|
assert check.get(RecoveryOperation, operation_id).status == "outcome_unknown"
|
||||||
|
assert (check.get(CampaignJob, job.id).send_status if channel == "smtp" else check.get(CampaignJob, job.id).imap_status) == "outcome_unknown"
|
||||||
|
assert check.query(SendAttempt if channel == "smtp" else ImapAppendAttempt).filter_by(job_id=job.id).one().status == "outcome_unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("channel_policy", ["mail_then_postbox", "postbox_then_mail", "mail_then_print", "mail_and_postbox"])
|
||||||
|
def test_mixed_delivered_status_without_smtp_attempt_evidence_never_claims_mail_acceptance(recovery, channel_policy):
|
||||||
|
_add(recovery, "mixed", status="delivered", delivery_channel_policy=channel_policy)
|
||||||
|
result = campaign_delivery_progress(recovery.session, tenant_id="tenant", campaign_id="campaign")
|
||||||
|
assert result["smtp"]["accepted"] == 0
|
||||||
|
if channel_policy == "mail_and_postbox":
|
||||||
|
assert result["smtp"]["outcome_unknown"] == 1
|
||||||
|
else:
|
||||||
|
assert result["smtp"]["excluded"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_legacy_reconciliation_cannot_overwrite_already_accepted_state(recovery):
|
||||||
|
job = _add(recovery, "legacy", status="outcome_unknown", attempt=1)
|
||||||
|
# Keep an old ORM projection, then commit another operator's decision.
|
||||||
|
with recovery.factory() as concurrent:
|
||||||
|
current = concurrent.get(CampaignJob, job.id)
|
||||||
|
current.send_status = "smtp_accepted"
|
||||||
|
concurrent.commit()
|
||||||
|
assert job.send_status == "outcome_unknown"
|
||||||
|
with pytest.raises(jobs.QueueingError, match="changed"):
|
||||||
|
jobs.reconcile_job_outcome(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, decision="not_sent", note="Stale operator evidence")
|
||||||
|
recovery.session.rollback()
|
||||||
|
assert job.send_status == "smtp_accepted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_smtp_decision_never_resolves_compound_postbox_operation(recovery):
|
||||||
|
job, lease, node, operation_id = _stale_claim(recovery, policy="mail_and_postbox")
|
||||||
|
meta = job_recovery_metadata(recovery.session, [job])[job.id]["smtp"]
|
||||||
|
recover_stale_delivery_claim(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id, channel="smtp", expected_revision=meta["revision"], note="Original process stopped")
|
||||||
|
recovery.session.commit()
|
||||||
|
jobs.reconcile_job_outcome(recovery.session, tenant_id="tenant", campaign_id="campaign", job_id=job.id,
|
||||||
|
decision="smtp_accepted", note="SMTP acceptance verified; Postbox remains separately unresolved")
|
||||||
|
assert job.send_status == "smtp_accepted"
|
||||||
|
assert recovery.session.get(RecoveryOperation, operation_id).status == "outcome_unknown"
|
||||||
|
assert verify_recovery_evidence_chain(recovery.session, operation_id)
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.27",
|
"version": "0.1.28",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
|
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
|
||||||
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
|
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.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 && node tests/delivery-mode-ui-structure.test.mjs",
|
"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 && node tests/delivery-mode-ui-structure.test.mjs",
|
||||||
"test:review-workflow": "node --experimental-strip-types --test tests/review-workflow-guidance.test.ts && node tests/review-workflow-guidance-ui-structure.test.mjs",
|
"test:review-workflow": "node --experimental-strip-types --test tests/review-workflow-guidance.test.ts tests/bulk-message-review.test.ts tests/built-message-state.test.ts tests/validation-issue-groups.test.ts && node tests/review-workflow-guidance-ui-structure.test.mjs",
|
||||||
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
|
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
|
||||||
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
|
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
|
||||||
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
||||||
|
|||||||
+44
-10
@@ -5,6 +5,7 @@ import {
|
|||||||
type ReferenceOptionProvider
|
type ReferenceOptionProvider
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
|
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
|
||||||
|
import { campaignVersionUpdateForRequest } from "../features/campaigns/utils/editorState";
|
||||||
export {
|
export {
|
||||||
fetchResourceAccessExplanation,
|
fetchResourceAccessExplanation,
|
||||||
fetchResourceAccessExplanationSubjects
|
fetchResourceAccessExplanationSubjects
|
||||||
@@ -340,6 +341,7 @@ export type CampaignScheduleCreate = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignVersionDetail = CampaignVersionListItem & {
|
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||||
|
review_build_token?: string | null;
|
||||||
raw_json: Record<string, unknown>;
|
raw_json: Record<string, unknown>;
|
||||||
campaign_json?: Record<string, unknown>;
|
campaign_json?: Record<string, unknown>;
|
||||||
mail_profile_migration_required?: boolean;
|
mail_profile_migration_required?: boolean;
|
||||||
@@ -936,6 +938,7 @@ export type CampaignSendNowPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignAppendSentPayload = {
|
export type CampaignAppendSentPayload = {
|
||||||
|
version_id?: string;
|
||||||
dry_run?: boolean;
|
dry_run?: boolean;
|
||||||
enqueue_celery?: boolean;
|
enqueue_celery?: boolean;
|
||||||
run_inline?: boolean;
|
run_inline?: boolean;
|
||||||
@@ -1017,6 +1020,7 @@ export type CampaignAttachmentLinkMatchesResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignMockSendPayload = {
|
export type CampaignMockSendPayload = {
|
||||||
|
use_reviewed_build?: boolean;
|
||||||
version_id?: string | null;
|
version_id?: string | null;
|
||||||
send?: boolean;
|
send?: boolean;
|
||||||
include_warnings?: boolean;
|
include_warnings?: boolean;
|
||||||
@@ -1027,6 +1031,10 @@ export type CampaignMockSendPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type CampaignReviewStatePayload = {
|
export type CampaignReviewStatePayload = {
|
||||||
|
merge_progress?: boolean;
|
||||||
|
build_token?: string | null;
|
||||||
|
base_revision?: number | null;
|
||||||
|
decision_category_key?: string | null;
|
||||||
inspection_complete: boolean;
|
inspection_complete: boolean;
|
||||||
reviewed_message_keys: string[];
|
reviewed_message_keys: string[];
|
||||||
issue_decisions: Array<{
|
issue_decisions: Array<{
|
||||||
@@ -1517,7 +1525,8 @@ options: CampaignWorkspaceQuery = {})
|
|||||||
export async function getCampaignWorkspaceDelta(
|
export async function getCampaignWorkspaceDelta(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
options: CampaignWorkspaceQuery = {})
|
options: CampaignWorkspaceQuery = {},
|
||||||
|
requestOptions?: Pick<RequestInit, "cache" | "signal">)
|
||||||
: Promise<CampaignWorkspaceDeltaResponse> {
|
: Promise<CampaignWorkspaceDeltaResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options.versionId) params.set("version_id", options.versionId);
|
if (options.versionId) params.set("version_id", options.versionId);
|
||||||
@@ -1527,7 +1536,7 @@ options: CampaignWorkspaceQuery = {})
|
|||||||
if (options.since) params.set("since", options.since);
|
if (options.since) params.set("since", options.since);
|
||||||
if (options.limit) params.set("limit", String(options.limit));
|
if (options.limit) params.set("limit", String(options.limit));
|
||||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`);
|
return apiFetch<CampaignWorkspaceDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/workspace/delta${suffix}`, requestOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCampaignVersions(
|
export async function listCampaignVersions(
|
||||||
@@ -1542,7 +1551,9 @@ settings: ApiSettings,
|
|||||||
campaignId: string,
|
campaignId: string,
|
||||||
versionId: string)
|
versionId: string)
|
||||||
: Promise<CampaignVersionDetail> {
|
: Promise<CampaignVersionDetail> {
|
||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`);
|
// Conflict reconciliation and discard/reload require the current revision,
|
||||||
|
// never an older coalesced or short-lived cached read.
|
||||||
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, { cache: "no-store" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unlockCampaignVersionValidation(
|
export async function unlockCampaignVersionValidation(
|
||||||
@@ -1595,7 +1606,7 @@ ifMatch: string)
|
|||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "If-Match": ifMatch },
|
headers: { "If-Match": ifMatch },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1607,7 +1618,7 @@ payload: CampaignVersionUpdatePayload = {})
|
|||||||
: Promise<CampaignCreateResponse> {
|
: Promise<CampaignCreateResponse> {
|
||||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
|
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/fork`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1621,7 +1632,7 @@ ifMatch: string)
|
|||||||
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
|
return apiFetch<CampaignVersionDetail>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/autosave`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "If-Match": ifMatch },
|
headers: { "If-Match": ifMatch },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(campaignVersionUpdateForRequest(payload))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1719,24 +1730,46 @@ versionId?: string)
|
|||||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
|
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CampaignDeliveryProgress = {
|
||||||
|
delivery_mode?: string | null;
|
||||||
|
workflow_state?: string;
|
||||||
|
campaign_id: string;
|
||||||
|
version_id: string;
|
||||||
|
generated_at: string;
|
||||||
|
total_jobs: number;
|
||||||
|
smtp: { total: number; processed: number; accepted: number; active: number; pending: number; failed: number; outcome_unknown: number; excluded: number; paused: number; cancelled: number };
|
||||||
|
imap: { total: number; processed: number; appended: number; active: number; pending: number; failed: number; outcome_unknown: number; excluded: number };
|
||||||
|
status_counts: { send: Record<string, number>; queue: Record<string, number>; imap: Record<string, number> };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCampaignDeliveryProgress(
|
||||||
|
settings: ApiSettings, campaignId: string, versionId: string, signal?: AbortSignal
|
||||||
|
): Promise<CampaignDeliveryProgress> {
|
||||||
|
return apiFetch<CampaignDeliveryProgress>(settings,
|
||||||
|
`/api/v1/campaigns/${campaignId}/delivery-progress?version_id=${encodeURIComponent(versionId)}`,
|
||||||
|
{ cache: "no-store", signal });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCampaignJobs(
|
export async function getCampaignJobs(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
options: CampaignJobsQuery = {})
|
options: CampaignJobsQuery = {},
|
||||||
|
init?: Pick<RequestInit, "cache" | "signal">)
|
||||||
: Promise<CampaignJobsResponse> {
|
: Promise<CampaignJobsResponse> {
|
||||||
const params = campaignJobsQueryParams(options);
|
const params = campaignJobsQueryParams(options);
|
||||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
|
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignJobsDelta(
|
export async function getCampaignJobsDelta(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
|
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {},
|
||||||
|
init?: Pick<RequestInit, "cache" | "signal">)
|
||||||
: Promise<CampaignJobsDeltaResponse> {
|
: Promise<CampaignJobsDeltaResponse> {
|
||||||
const params = campaignJobsQueryParams(options);
|
const params = campaignJobsQueryParams(options);
|
||||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||||
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
|
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCampaignJobDetail(
|
export async function getCampaignJobDetail(
|
||||||
@@ -1941,6 +1974,7 @@ payload: CampaignAppendSentPayload = {})
|
|||||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
|
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/append-sent`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
version_id: payload.version_id,
|
||||||
dry_run: payload.dry_run ?? false,
|
dry_run: payload.dry_run ?? false,
|
||||||
enqueue_celery: payload.enqueue_celery ?? true,
|
enqueue_celery: payload.enqueue_celery ?? true,
|
||||||
run_inline: payload.run_inline ?? false
|
run_inline: payload.run_inline ?? false
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type CampaignDeliveryPolicyScope = "system" | "tenant";
|
||||||
|
export type CampaignDeliveryPolicy = {
|
||||||
|
scope: CampaignDeliveryPolicyScope;
|
||||||
|
synchronous_send_max_recipients: number | null;
|
||||||
|
revision: string;
|
||||||
|
max_configurable_recipients: number;
|
||||||
|
effective_max_recipients: number;
|
||||||
|
inherited_max_recipients: number;
|
||||||
|
absolute_max_recipients: number;
|
||||||
|
deployment_ceiling_explicit: boolean;
|
||||||
|
deployment_max_recipients: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCampaignDeliveryPolicy(settings: ApiSettings, scope: CampaignDeliveryPolicyScope, signal?: AbortSignal) {
|
||||||
|
return apiFetch<CampaignDeliveryPolicy>(settings, `/api/v1/campaigns/settings/delivery-policy/${scope}`, { cache: "no-store", signal });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCampaignDeliveryPolicy(settings: ApiSettings, scope: CampaignDeliveryPolicyScope, value: number | null, revision: string) {
|
||||||
|
return apiFetch<CampaignDeliveryPolicy>(settings, `/api/v1/campaigns/settings/delivery-policy/${scope}`, {
|
||||||
|
method: "PUT", body: JSON.stringify({ synchronous_send_max_recipients: value, expected_revision: revision })
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
export type CampaignRecoveryChannel = "smtp" | "imap";
|
||||||
|
export type CampaignInlineRecoveryAction = "retry" | "send-unattempted";
|
||||||
|
export type CampaignInlineRecoveryResult = {
|
||||||
|
selected_count: number;
|
||||||
|
remaining_count?: number;
|
||||||
|
attempted_count?: number;
|
||||||
|
sent_count?: number;
|
||||||
|
failed_count?: number;
|
||||||
|
outcome_unknown_count?: number;
|
||||||
|
enqueued_count?: number;
|
||||||
|
run_inline?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runCampaignInlineRecovery(settings: ApiSettings, campaignId: string, action: CampaignInlineRecoveryAction, payload: {
|
||||||
|
version_id: string;
|
||||||
|
job_ids: string[];
|
||||||
|
include_permanent?: boolean;
|
||||||
|
}): Promise<CampaignInlineRecoveryResult> {
|
||||||
|
const response = await apiFetch<{ result: CampaignInlineRecoveryResult }>(settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/jobs/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ ...payload, run_inline: true, enqueue_celery: false })
|
||||||
|
});
|
||||||
|
return response.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverCampaignJobClaim(settings: ApiSettings, campaignId: string, jobId: string, payload: {
|
||||||
|
channel: CampaignRecoveryChannel;
|
||||||
|
expected_revision: string;
|
||||||
|
note: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await apiFetch(settings, `/api/v1/campaigns/${encodeURIComponent(campaignId)}/jobs/${encodeURIComponent(jobId)}/recover-claim`, {
|
||||||
|
method: "POST", body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
+12
-6
@@ -72,8 +72,10 @@ export async function testMailProfileSmtp(
|
|||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
credentialId?: string | null,
|
credentialId?: string | null,
|
||||||
campaignId?: string | null
|
campaignId?: string | null
|
||||||
): Promise<MailConnectionTestResponse> {
|
): Promise<MailConnectionTestResponse & { protocol: "smtp" }> {
|
||||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
|
const result = await runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
|
||||||
|
if (result.protocol !== "smtp") throw new Error("Unexpected protocol in SMTP test response.");
|
||||||
|
return { ...result, protocol: result.protocol };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testMailProfileImap(
|
export async function testMailProfileImap(
|
||||||
@@ -82,8 +84,10 @@ export async function testMailProfileImap(
|
|||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
credentialId?: string | null,
|
credentialId?: string | null,
|
||||||
campaignId?: string | null
|
campaignId?: string | null
|
||||||
): Promise<MailConnectionTestResponse> {
|
): Promise<MailConnectionTestResponse & { protocol: "imap" }> {
|
||||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
|
const result = await runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
|
||||||
|
if (result.protocol !== "imap") throw new Error("Unexpected protocol in IMAP test response.");
|
||||||
|
return { ...result, protocol: result.protocol };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMailProfileImapFolders(
|
export async function listMailProfileImapFolders(
|
||||||
@@ -92,8 +96,10 @@ export async function listMailProfileImapFolders(
|
|||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
credentialId?: string | null,
|
credentialId?: string | null,
|
||||||
campaignId?: string | null
|
campaignId?: string | null
|
||||||
): Promise<MailImapFolderListResponse> {
|
): Promise<MailImapFolderListResponse & { protocol: "imap" }> {
|
||||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
|
const result = await runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
|
||||||
|
if (result.protocol !== "imap") throw new Error("Unexpected protocol in IMAP folder response.");
|
||||||
|
return { ...result, protocol: result.protocol };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
AdminPageLayout, Card, DescriptionItem, DescriptionList, FormField, PageActionBar,
|
||||||
|
ToggleSwitch, adminErrorMessage, usePlatformLanguage, useUnsavedDraftGuard, type ApiSettings
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { getCampaignDeliveryPolicy, saveCampaignDeliveryPolicy, type CampaignDeliveryPolicy, type CampaignDeliveryPolicyScope } from "../../api/deliveryPolicy";
|
||||||
|
|
||||||
|
// This optional administration screen owns its bilingual copy and loads lazily.
|
||||||
|
const labels = {
|
||||||
|
en: {
|
||||||
|
system: "System Campaign delivery", tenant: "Tenant Campaign delivery", title: "Interactive delivery limit",
|
||||||
|
description: "Set the recipient-job limit for one Send now request. This is not a campaign-size limit; background workers process larger campaigns separately.",
|
||||||
|
inherit: "Inherit the parent or default limit", limit: "Maximum recipient jobs per Send now request", help: "0 disables Send now. Larger values hold requests open longer and may reach proxy timeouts; Mail provider rate limits still apply. Saving policy never sends messages or changes existing reviews.",
|
||||||
|
effective: "Saved effective limit", parent: "Inherited limit", maximum: "Maximum allowed here", deployment: "Explicit deployment ceiling", absent: "Not set; the absolute safety maximum is 500.",
|
||||||
|
saved: "Campaign delivery policy saved.", invalid: "Enter a whole number within the permitted range.", readOnly: "You may inspect this policy but do not have permission to change it.",
|
||||||
|
reload: "Reload saved delivery policy", save: "Save", discard: "Discard", ceiling: "Tenant settings can only narrow system policy. An explicit deployment ceiling cannot be raised in this screen."
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
system: "Systemweiter Campaign-Versand", tenant: "Mandantenweiter Campaign-Versand", title: "Grenze für interaktiven Versand",
|
||||||
|
description: "Legen Sie die Empfängerauftragsgrenze für eine Anfrage „Jetzt senden“ fest. Sie begrenzt nicht die Kampagnengröße; Hintergrund-Worker verarbeiten größere Kampagnen getrennt.",
|
||||||
|
inherit: "Übergeordnete Grenze oder Standard erben", limit: "Maximale Empfängeraufträge je Anfrage „Jetzt senden“", help: "0 deaktiviert „Jetzt senden“. Höhere Werte halten Anfragen länger offen und können Proxy-Zeitlimits erreichen; Mail-Anbieterraten gelten weiterhin. Speichern versendet keine Nachrichten und ändert keine bestehenden Prüfungen.",
|
||||||
|
effective: "Gespeicherte wirksame Grenze", parent: "Geerbte Grenze", maximum: "Hier maximal zulässig", deployment: "Ausdrückliche Bereitstellungsgrenze", absent: "Nicht gesetzt; die absolute Sicherheitsgrenze beträgt 500.",
|
||||||
|
saved: "Campaign-Versandrichtlinie gespeichert.", invalid: "Geben Sie eine ganze Zahl im zulässigen Bereich ein.", readOnly: "Sie dürfen diese Richtlinie ansehen, besitzen aber keine Änderungsberechtigung.",
|
||||||
|
reload: "Gespeicherte Versandrichtlinie neu laden", save: "Speichern", discard: "Verwerfen", ceiling: "Mandanteneinstellungen dürfen die Systemrichtlinie nur einschränken. Eine ausdrücklich gesetzte Bereitstellungsgrenze lässt sich hier nicht erhöhen."
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CampaignDeliveryPolicyPanel({ settings, scope, canWrite }: {
|
||||||
|
settings: ApiSettings; scope: CampaignDeliveryPolicyScope; canWrite: boolean;
|
||||||
|
}) {
|
||||||
|
const { language } = usePlatformLanguage();
|
||||||
|
const text = labels[language.startsWith("de") ? "de" : "en"];
|
||||||
|
const [saved, setSaved] = useState<CampaignDeliveryPolicy | null>(null);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [inherit, setInherit] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
const generation = useRef(0);
|
||||||
|
const request = useRef<AbortController | null>(null);
|
||||||
|
const inFlight = useRef<Promise<boolean> | null>(null);
|
||||||
|
const dirty = Boolean(saved && (inherit !== (saved.synchronous_send_max_recipients === null) || (!inherit && draft !== String(saved.synchronous_send_max_recipients))));
|
||||||
|
const valid = inherit || (/^\d+$/.test(draft) && Number(draft) <= (saved?.max_configurable_recipients ?? 0));
|
||||||
|
|
||||||
|
function adopt(state: CampaignDeliveryPolicy) {
|
||||||
|
setSaved(state); setInherit(state.synchronous_send_max_recipients === null);
|
||||||
|
setDraft(String(state.synchronous_send_max_recipients ?? state.inherited_max_recipients));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
request.current?.abort();
|
||||||
|
const controller = new AbortController(); request.current = controller;
|
||||||
|
const current = ++generation.current;
|
||||||
|
setLoading(true); setError(""); setSuccess("");
|
||||||
|
try {
|
||||||
|
const response = await getCampaignDeliveryPolicy(settings, scope, controller.signal);
|
||||||
|
if (current === generation.current) adopt(response);
|
||||||
|
} catch (cause) {
|
||||||
|
if (current === generation.current && !controller.signal.aborted) setError(adminErrorMessage(cause));
|
||||||
|
} finally { if (current === generation.current) setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSaved(null);
|
||||||
|
setSaving(false); inFlight.current = null;
|
||||||
|
void reload();
|
||||||
|
return () => { ++generation.current; request.current?.abort(); };
|
||||||
|
}, [scope, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
|
function discard() { if (saved && !inFlight.current) { adopt(saved); setError(""); setSuccess(""); } }
|
||||||
|
function save(): Promise<boolean> {
|
||||||
|
if (inFlight.current) return inFlight.current;
|
||||||
|
if (!dirty) return Promise.resolve(true);
|
||||||
|
if (!saved || !canWrite || !valid || loading) return Promise.resolve(false);
|
||||||
|
const current = generation.current;
|
||||||
|
setSaving(true); setError(""); setSuccess("");
|
||||||
|
const operation = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await saveCampaignDeliveryPolicy(settings, scope, inherit ? null : Number(draft), saved.revision);
|
||||||
|
if (current !== generation.current) return false;
|
||||||
|
adopt(response); setSuccess(text.saved); return true;
|
||||||
|
} catch (cause) {
|
||||||
|
if (current === generation.current) setError(adminErrorMessage(cause));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (current === generation.current) { inFlight.current = null; setSaving(false); }
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
inFlight.current = operation;
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
|
useUnsavedDraftGuard({ dirty, onSave: save, onDiscard: discard });
|
||||||
|
const disabled = !canWrite || saving || loading;
|
||||||
|
return <AdminPageLayout archetype="editor" title={text[scope]} description={text.description}
|
||||||
|
loading={loading && !saved} error={error} success={success}
|
||||||
|
interfaceId={`campaigns.admin.${scope}-delivery`} helpModuleId="campaigns" helpTopicId="campaigns.admin.delivery-policy"
|
||||||
|
actions={<PageActionBar variant="editor" state={saving ? "saving" : !valid ? "invalid" : error && dirty ? "save-failed" : dirty ? "dirty" : "clean"}
|
||||||
|
refreshable reloadAction={{ onReload: () => void reload(), label: text.reload, loading, disabled: saving }}
|
||||||
|
saveAction={{ label: text.save, onClick: () => void save(), disabled: !canWrite || !saved || loading }}
|
||||||
|
discardAction={{ label: text.discard, onClick: discard, disabled: saving }} />}>
|
||||||
|
{saved && <Card title={text.title}>
|
||||||
|
{!canWrite && <p>{text.readOnly}</p>}
|
||||||
|
<ToggleSwitch label={text.inherit} checked={inherit} disabled={disabled} onChange={setInherit} />
|
||||||
|
<FormField label={text.limit} help={text.help}>
|
||||||
|
<input type="number" min={0} max={saved.max_configurable_recipients} step={1} value={draft}
|
||||||
|
aria-invalid={!valid} disabled={disabled || inherit} onChange={(event) => setDraft(event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
{!valid && <p role="alert">{text.invalid}</p>}
|
||||||
|
<p>{text.ceiling}</p>
|
||||||
|
<DescriptionList>
|
||||||
|
<DescriptionItem term={text.effective}>{saved.effective_max_recipients}</DescriptionItem>
|
||||||
|
<DescriptionItem term={text.parent}>{saved.inherited_max_recipients}</DescriptionItem>
|
||||||
|
<DescriptionItem term={text.maximum}>{saved.max_configurable_recipients}</DescriptionItem>
|
||||||
|
<DescriptionItem term={text.deployment}>{saved.deployment_ceiling_explicit ? saved.deployment_max_recipients : text.absent}</DescriptionItem>
|
||||||
|
</DescriptionList>
|
||||||
|
</Card>}
|
||||||
|
</AdminPageLayout>;
|
||||||
|
}
|
||||||
@@ -3,10 +3,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings, AuthInfo } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
import {
|
import type { CampaignArchiveEncryptionPolicy } from "../../api/campaigns";
|
||||||
getCampaignArchiveEncryptionPolicy,
|
|
||||||
type CampaignArchiveEncryptionPolicy
|
|
||||||
} from "../../api/campaigns";
|
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
@@ -14,6 +11,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
|||||||
import { MetricCard } from "@govoplan/core-webui";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
@@ -21,7 +19,7 @@ import { ConfirmDialog } from "@govoplan/core-webui";
|
|||||||
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asArray, asRecord, getCampaignJson, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { updateNested } from "./utils/draftEditor";
|
import { updateNested } from "./utils/draftEditor";
|
||||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||||
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
||||||
@@ -29,21 +27,11 @@ import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePa
|
|||||||
import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||||
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||||
|
import CampaignArchiveEncryptionPolicyNotice, { UNAVAILABLE_ARCHIVE_POLICY } from "./components/CampaignArchiveEncryptionPolicyNotice";
|
||||||
|
|
||||||
type PathChooserState = {index: number;};
|
type PathChooserState = {index: number;};
|
||||||
type IndividualDisableState = {index: number;usageCount: number;};
|
type IndividualDisableState = {index: number;usageCount: number;};
|
||||||
|
|
||||||
const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = {
|
|
||||||
available: false,
|
|
||||||
allowed_password_encryption_methods: ["aes"],
|
|
||||||
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
|
|
||||||
policy_hash: "",
|
|
||||||
source_path: [],
|
|
||||||
reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.",
|
|
||||||
diagnostics: [],
|
|
||||||
legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||||
@@ -59,7 +47,7 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
|||||||
const [archivePolicy, setArchivePolicy] = useState<CampaignArchiveEncryptionPolicy>(UNAVAILABLE_ARCHIVE_POLICY);
|
const [archivePolicy, setArchivePolicy] = useState<CampaignArchiveEncryptionPolicy>(UNAVAILABLE_ARCHIVE_POLICY);
|
||||||
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, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saving, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -94,7 +82,10 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
|||||||
!archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10
|
!archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message && !legacyConfigurationInvalid;
|
const zipChanged = JSON.stringify(attachments.zip ?? null) !== JSON.stringify(asRecord(getCampaignJson(version).attachments).zip ?? null);
|
||||||
|
// An unchanged ZIP policy must not lock unrelated source/rule corrections.
|
||||||
|
// The backend rechecks changed ZIP settings and all actual archive use.
|
||||||
|
const canSave = dirty && !locked && Boolean(draft) && (!zipChanged || !zipArchiveNameValidation.message && !legacyConfigurationInvalid);
|
||||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||||
const attachmentPreviewEntry = useMemo(
|
const attachmentPreviewEntry = useMemo(
|
||||||
@@ -118,22 +109,6 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
|||||||
return () => {cancelled = true;};
|
return () => {cancelled = true;};
|
||||||
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
setArchivePolicy(UNAVAILABLE_ARCHIVE_POLICY);
|
|
||||||
void getCampaignArchiveEncryptionPolicy(settings, campaignId)
|
|
||||||
.then((policy) => { if (!cancelled) setArchivePolicy(policy); })
|
|
||||||
.catch((cause) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setArchivePolicy({
|
|
||||||
...UNAVAILABLE_ARCHIVE_POLICY,
|
|
||||||
reason: cause instanceof Error ? cause.message : String(cause)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
||||||
|
|
||||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
const normalized = ensureAttachmentBasePaths(paths);
|
const normalized = ensureAttachmentBasePaths(paths);
|
||||||
@@ -321,16 +296,17 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
|||||||
mode="workspace"
|
mode="workspace"
|
||||||
title="i18n:govoplan-campaign.attachments.6771ade6"
|
title="i18n:govoplan-campaign.attachments.6771ade6"
|
||||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
headerLoading={loading}
|
headerLoading={loading || saving}
|
||||||
error={error}
|
error={error}
|
||||||
actions={<PageActionBar
|
actions={<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
contextActions={filesModuleInstalled ? <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button> : undefined}
|
contextActions={filesModuleInstalled ? <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button> : undefined}
|
||||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current editor blocker before saving." : undefined }}
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current editor blocker before saving." : undefined }}
|
||||||
/>}
|
/>}
|
||||||
notices={(localError || locked) ? <>
|
notices={(localError || locked || version?.mail_profile_migration_required) ? <>
|
||||||
|
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</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" />}
|
{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" />}
|
||||||
</> : undefined}
|
</> : undefined}
|
||||||
@@ -346,11 +322,12 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
|||||||
</MetricGrid>
|
</MetricGrid>
|
||||||
|
|
||||||
<Card id="campaign-attachment-sources" tabIndex={-1} title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
<Card id="campaign-attachment-sources" tabIndex={-1} title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
||||||
|
{filesModuleInstalled && !managedFilesAvailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.file_chooser_unavailable</DismissibleAlert>}
|
||||||
<div className="admin-table-surface attachment-sources-table-surface">
|
<div className="admin-table-surface attachment-sources-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-attachment-sources`}
|
id={`campaign-${campaignId}-attachment-sources`}
|
||||||
rows={basePaths}
|
rows={basePaths}
|
||||||
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
columns={attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser })}
|
||||||
getRowKey={(basePath) => basePath.id}
|
getRowKey={(basePath) => basePath.id}
|
||||||
emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
|
emptyText="i18n:govoplan-campaign.no_attachment_sources_configured.48664606"
|
||||||
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
|
emptyAction={<DataGridEmptyAction onAdd={() => addBasePath(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_attachment_source.cefa7882" />}
|
||||||
@@ -425,11 +402,7 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||||
<DismissibleAlert tone={legacyZipCryptoAllowed ? "warning" : "info"} dismissible={false} compact>
|
<CampaignArchiveEncryptionPolicyNotice settings={settings} auth={auth} campaignId={campaignId} onPolicyChange={setArchivePolicy} />
|
||||||
<strong>{archivePolicy.legacy_label}</strong>: {archivePolicy.reason}
|
|
||||||
{archivePolicy.source_path.length > 0 && <> Source: {archivePolicy.source_path.map((step) => step.label).join(" → ")}.</>}
|
|
||||||
{!canUseLegacyZipCrypto && <> Your account does not have the dedicated legacy-encryption permission.</>}
|
|
||||||
</DismissibleAlert>
|
|
||||||
<div className="attachment-zip-master-toggle">
|
<div className="attachment-zip-master-toggle">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||||
@@ -481,7 +454,7 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
zipConfig={zipConfig}
|
zipConfig={zipConfig}
|
||||||
filesModuleInstalled={managedFilesAvailable}
|
filesModuleInstalled={filesModuleInstalled}
|
||||||
previewContext={attachmentPreviewContext}
|
previewContext={attachmentPreviewContext}
|
||||||
onChange={(rules) => patch(["attachments", "global"], rules)} />
|
onChange={(rules) => patch(["attachments", "global"], rules)} />
|
||||||
|
|
||||||
@@ -806,6 +779,7 @@ type AttachmentSourceColumnContext = {
|
|||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
fileSpaces: FilesFileSpace[];
|
fileSpaces: FilesFileSpace[];
|
||||||
managedFilesAvailable: boolean;
|
managedFilesAvailable: boolean;
|
||||||
|
filesModuleInstalled: boolean;
|
||||||
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
patchBasePath: (index: number, patch: Partial<AttachmentBasePath>) => void;
|
||||||
setIndividualEligibility: (index: number, checked: boolean) => void;
|
setIndividualEligibility: (index: number, checked: boolean) => void;
|
||||||
addBasePath: (afterIndex?: number) => void;
|
addBasePath: (afterIndex?: number) => void;
|
||||||
@@ -814,7 +788,7 @@ type AttachmentSourceColumnContext = {
|
|||||||
setPathChooser: (state: PathChooserState | null) => void;
|
setPathChooser: (state: PathChooserState | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAvailable, filesModuleInstalled, patchBasePath, setIndividualEligibility, addBasePath, moveBasePath, removeBasePath, setPathChooser }: AttachmentSourceColumnContext): DataGridColumn<AttachmentBasePath>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="i18n:govoplan-campaign.campaign_files.96e7004b" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: 220, resizable: true, sortable: true, filterable: true, sticky: "start", render: (basePath, index) => <input value={basePath.name} disabled={locked} placeholder="i18n:govoplan-campaign.campaign_files.96e7004b" onChange={(event) => patchBasePath(index, { name: event.target.value })} />, value: (basePath) => basePath.name },
|
||||||
{
|
{
|
||||||
@@ -830,9 +804,9 @@ function attachmentSourceColumns({ locked, basePaths, fileSpaces, managedFilesAv
|
|||||||
<input
|
<input
|
||||||
className="chooser-display-input"
|
className="chooser-display-input"
|
||||||
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
value={managedFilesAvailable ? formatAttachmentSourcePath(basePath, fileSpaces) : basePath.path}
|
||||||
disabled={locked}
|
disabled={locked || filesModuleInstalled && !managedFilesAvailable}
|
||||||
readOnly={managedFilesAvailable}
|
readOnly={managedFilesAvailable}
|
||||||
tabIndex={managedFilesAvailable ? -1 : undefined}
|
tabIndex={0}
|
||||||
placeholder="i18n:govoplan-campaign.attachments_placeholder"
|
placeholder="i18n:govoplan-campaign.attachments_placeholder"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
|
if (!managedFilesAvailable) patchBasePath(index, { path: event.target.value, source: "" });
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
|||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
@@ -22,7 +23,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, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saving, saveState, setSaveState, localError, setLocalError, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -155,15 +156,16 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
|||||||
mode="workspace"
|
mode="workspace"
|
||||||
title="i18n:govoplan-campaign.fields.e8b68527"
|
title="i18n:govoplan-campaign.fields.e8b68527"
|
||||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
headerLoading={loading}
|
headerLoading={loading || saving}
|
||||||
error={error}
|
error={error}
|
||||||
actions={<PageActionBar
|
actions={<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: saveFields, disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current field-definition blocker before saving." : undefined }}
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: saveFields, disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current field-definition blocker before saving." : undefined }}
|
||||||
/>}
|
/>}
|
||||||
notices={(localError || fieldNameWarning || locked) ? <>
|
notices={(localError || fieldNameWarning || locked || version?.mail_profile_migration_required) ? <>
|
||||||
|
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</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" />}
|
{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" />}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DescriptionList } from "@govoplan/core-webui";
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Check, RotateCcw, Search, X } from "lucide-react";
|
import { Check, RotateCcw, Search, X } from "lucide-react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
import {
|
import {
|
||||||
downloadCampaignJobsCsv,
|
downloadCampaignJobsCsv,
|
||||||
emailCampaignReport,
|
emailCampaignReport,
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
getCampaignJobs,
|
getCampaignJobs,
|
||||||
resolveCampaignJobOutcome,
|
resolveCampaignJobOutcome,
|
||||||
retryCampaignJobs,
|
retryCampaignJobs,
|
||||||
sendCampaignJob,
|
|
||||||
sendUnattemptedCampaignJobs,
|
sendUnattemptedCampaignJobs,
|
||||||
type CampaignJobDetailResponse,
|
type CampaignJobDetailResponse,
|
||||||
type CampaignJobsResponse,
|
type CampaignJobsResponse,
|
||||||
@@ -17,15 +16,19 @@ import {
|
|||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
import { ContentGrid, Card } from "@govoplan/core-webui";
|
import { ContentGrid, Card } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
||||||
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
import { LoadingFrame, TableActionGroup, ToggleSwitch, hasScope, i18nMessage } from "@govoplan/core-webui";
|
||||||
|
import { recoverCampaignJobClaim, runCampaignInlineRecovery, type CampaignInlineRecoveryAction } from "../../api/deliveryRecovery";
|
||||||
|
import ReportRecipients, { reportRecipientSearchText } from "./reporting/ReportRecipients";
|
||||||
|
import ReportRecoveryDialog from "./reporting/ReportRecoveryDialog";
|
||||||
|
import { reportClaimRecovery, reportRecoveryHints, reportRetryableFailure, reportUnattempted, type ReportReconciliation } from "./reporting/reportRecovery";
|
||||||
|
import CampaignDeliveryProgressDialog from "./review/CampaignDeliveryProgressDialog";
|
||||||
|
import { SEND_STATUS_OPTIONS, IMAP_STATUS_OPTIONS, deliveryStatusLabel } from "./utils/deliveryStatusOptions";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
||||||
import { emptyCampaignJobsResponse } from "./utils/jobDeltas";
|
import { emptyCampaignJobsResponse } from "./utils/jobDeltas";
|
||||||
@@ -38,24 +41,6 @@ import {
|
|||||||
type ReportGridShortcutId
|
type ReportGridShortcutId
|
||||||
} from "./utils/reportGridShortcuts";
|
} from "./utils/reportGridShortcuts";
|
||||||
|
|
||||||
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
|
||||||
"not_queued",
|
|
||||||
"skipped",
|
|
||||||
"queued",
|
|
||||||
"claimed",
|
|
||||||
"sending",
|
|
||||||
"smtp_accepted",
|
|
||||||
"postbox_accepted",
|
|
||||||
"print_accepted",
|
|
||||||
"delivered",
|
|
||||||
"partially_accepted",
|
|
||||||
"sent",
|
|
||||||
"outcome_unknown",
|
|
||||||
"failed_temporary",
|
|
||||||
"failed_permanent",
|
|
||||||
"cancelled"].
|
|
||||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
|
||||||
|
|
||||||
const PRINT_STATUS_OPTIONS: DataGridListOption[] = [
|
const PRINT_STATUS_OPTIONS: DataGridListOption[] = [
|
||||||
"not_requested",
|
"not_requested",
|
||||||
"ready",
|
"ready",
|
||||||
@@ -77,16 +62,6 @@ const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
|
|||||||
"skipped"].
|
"skipped"].
|
||||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||||
|
|
||||||
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
|
|
||||||
"not_requested",
|
|
||||||
"pending",
|
|
||||||
"appending",
|
|
||||||
"appended",
|
|
||||||
"outcome_unknown",
|
|
||||||
"failed",
|
|
||||||
"skipped"].
|
|
||||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
|
||||||
|
|
||||||
const VALIDATION_STATUS_OPTIONS: DataGridListOption[] = [
|
const VALIDATION_STATUS_OPTIONS: DataGridListOption[] = [
|
||||||
"ready",
|
"ready",
|
||||||
"warning",
|
"warning",
|
||||||
@@ -106,17 +81,25 @@ map((value) => ({ value, label: humanize(value) }));
|
|||||||
|
|
||||||
const JOB_GRID_QUERY_DELAY_MS = 300;
|
const JOB_GRID_QUERY_DELAY_MS = 300;
|
||||||
|
|
||||||
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
|
export default function CampaignReportPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||||
|
|
||||||
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
|
||||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const cards = data.summary?.cards;
|
const cards = data.summary?.cards;
|
||||||
|
const sendCounts = data.summary?.status_counts?.send ?? {};
|
||||||
|
const imapCounts = data.summary?.status_counts?.imap ?? {};
|
||||||
const delivery = asRecord(data.summary?.delivery);
|
const delivery = asRecord(data.summary?.delivery);
|
||||||
const postboxReceipts = asRecord(data.summary?.postbox_receipts);
|
const postboxReceipts = asRecord(data.summary?.postbox_receipts);
|
||||||
const retention = data.summary?.retention;
|
const retention = data.summary?.retention;
|
||||||
const rateLimit = asRecord(delivery.rate_limit);
|
const rateLimit = asRecord(delivery.rate_limit);
|
||||||
const imapPolicy = asRecord(delivery.imap_append_sent);
|
const imapPolicy = asRecord(delivery.imap_append_sent);
|
||||||
|
const canRetry = hasScope(auth, "campaigns:campaign:retry");
|
||||||
|
const canSend = hasScope(auth, "campaigns:campaign:send");
|
||||||
|
const canQueue = hasScope(auth, "campaigns:campaign:queue");
|
||||||
|
const canReconcile = hasScope(auth, "campaigns:campaign:reconcile");
|
||||||
|
const workersAvailable = delivery.background_workers_enabled === true || delivery.celery_enabled === true;
|
||||||
|
const reportContext = JSON.stringify([campaignId, version?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.tenant.id, auth.user.id]);
|
||||||
|
const reportContextRef = useRef(reportContext);
|
||||||
|
reportContextRef.current = reportContext;
|
||||||
|
|
||||||
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
||||||
const jobsRequestRef = useRef(0);
|
const jobsRequestRef = useRef(0);
|
||||||
@@ -140,7 +123,18 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
const [emailRecipients, setEmailRecipients] = useState("");
|
const [emailRecipients, setEmailRecipients] = useState("");
|
||||||
const [attachCsv, setAttachCsv] = useState(true);
|
const [attachCsv, setAttachCsv] = useState(true);
|
||||||
const [attachJson, setAttachJson] = useState(false);
|
const [attachJson, setAttachJson] = useState(false);
|
||||||
const [reconcile, setReconcile] = useState<ReconcileRequest>(null);
|
const [reconcile, setReconcile] = useState<ReportReconciliation | null>(null);
|
||||||
|
const pendingAction = useRef(false);
|
||||||
|
const [progress, setProgress] = useState<{ versionId: string; requestState: "running" | "finished" | "interrupted"; requestError?: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
++jobsRequestRef.current;
|
||||||
|
setJobs(emptyCampaignJobsResponse());
|
||||||
|
setDetail(null); setReconcile(null); setProgress(null);
|
||||||
|
setActionError(""); setActionMessage(""); setBusyAction("");
|
||||||
|
pendingAction.current = false;
|
||||||
|
setPage(1);
|
||||||
|
}, [reportContext]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handle = window.setTimeout(() => {
|
const handle = window.setTimeout(() => {
|
||||||
@@ -172,6 +166,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
||||||
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
|
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
|
||||||
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
|
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
|
||||||
|
{ label: "i18n:govoplan-campaign.delivery_status_sending", value: (sendCounts.claimed ?? 0) + (sendCounts.sending ?? 0), shortcutId: "smtp_active" },
|
||||||
|
{ label: "i18n:govoplan-campaign.delivery_status_queued", value: sendCounts.queued ?? 0, shortcutId: "smtp_queued" },
|
||||||
{ label: "Postbox accepted", value: cards?.postbox_accepted ?? 0, shortcutId: "postbox_accepted" },
|
{ label: "Postbox accepted", value: cards?.postbox_accepted ?? 0, shortcutId: "postbox_accepted" },
|
||||||
{ label: "Print accepted", value: cards?.print_accepted ?? 0, shortcutId: "print_accepted" },
|
{ label: "Print accepted", value: cards?.print_accepted ?? 0, shortcutId: "print_accepted" },
|
||||||
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
|
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
|
||||||
@@ -181,13 +177,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
{ label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" }
|
{ label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" }
|
||||||
];
|
];
|
||||||
const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
||||||
|
{ label: "i18n:govoplan-campaign.delivery_status_pending", value: imapCounts.pending ?? 0, shortcutId: "imap_pending" },
|
||||||
|
{ label: "i18n:govoplan-campaign.delivery_status_appending", value: imapCounts.appending ?? 0, shortcutId: "imap_active" },
|
||||||
|
{ label: "i18n:govoplan-campaign.delivery_status_outcome_unknown", value: imapCounts.outcome_unknown ?? 0, shortcutId: "imap_unknown" },
|
||||||
{ label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" },
|
{ label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" },
|
||||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" },
|
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" },
|
||||||
{ label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" }
|
{ label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" }
|
||||||
];
|
];
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async () => {
|
||||||
if (!campaignId) return;
|
if (!campaignId || !version?.id) return;
|
||||||
const requestId = ++jobsRequestRef.current;
|
const requestId = ++jobsRequestRef.current;
|
||||||
setJobsLoading(true);
|
setJobsLoading(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
@@ -200,16 +199,16 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
sortBy: campaignJobSortColumn(appliedJobGridQuery.sort?.columnId),
|
sortBy: campaignJobSortColumn(appliedJobGridQuery.sort?.columnId),
|
||||||
sortDirection: appliedJobGridQuery.sort?.direction ?? "asc",
|
sortDirection: appliedJobGridQuery.sort?.direction ?? "asc",
|
||||||
filters: appliedJobGridQuery.filters
|
filters: appliedJobGridQuery.filters
|
||||||
});
|
}, { cache: "no-store" });
|
||||||
if (requestId !== jobsRequestRef.current) return;
|
if (requestId !== jobsRequestRef.current || reportContext !== reportContextRef.current) return;
|
||||||
setJobs(response);
|
setJobs(response);
|
||||||
if (response.pages > 0 && page > response.pages) setPage(response.pages);
|
if (response.pages > 0 && page > response.pages) setPage(response.pages);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId === jobsRequestRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
if (requestId === jobsRequestRef.current && reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
if (requestId === jobsRequestRef.current) setJobsLoading(false);
|
if (requestId === jobsRequestRef.current && reportContext === reportContextRef.current) setJobsLoading(false);
|
||||||
}
|
}
|
||||||
}, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery]);
|
}, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery, reportContext]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadJobs();
|
void loadJobs();
|
||||||
@@ -220,7 +219,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runExplicitAction(action: "retry" | "unattempted") {
|
async function runExplicitAction(action: "retry" | "unattempted") {
|
||||||
if (!version || busyAction) return;
|
if (!version || busyAction || pendingAction.current || !workersAvailable || (action === "retry" ? !canRetry : !canQueue)) return;
|
||||||
|
pendingAction.current = true;
|
||||||
setBusyAction(action);
|
setBusyAction(action);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
setActionMessage("");
|
setActionMessage("");
|
||||||
@@ -228,94 +228,74 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
const response = action === "retry" ?
|
const response = action === "retry" ?
|
||||||
await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) :
|
await retryCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true }) :
|
||||||
await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
await sendUnattemptedCampaignJobs(settings, campaignId, { version_id: version.id, enqueue_celery: true });
|
||||||
|
if (reportContext !== reportContextRef.current) return;
|
||||||
const result = asRecord(response.result ?? response);
|
const result = asRecord(response.result ?? response);
|
||||||
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
setActionMessage(`${humanize(String(result.action ?? action))}: ${String(result.selected_count ?? 0)} job(s) selected, ${String(result.enqueued_count ?? 0)} enqueued.`);
|
||||||
await reloadAll();
|
await reloadAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err instanceof Error ? err.message : String(err));
|
if (reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setBusyAction("");
|
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const failedRowsOnPage = useMemo(
|
const failedRowsOnPage = useMemo(
|
||||||
() => jobs.jobs.filter((row) => retryableFailedStatus(String(row.send_status ?? "")) && String(row.id ?? "")),
|
() => jobs.jobs.filter((row) => reportRetryableFailure(row) && String(row.id ?? "")),
|
||||||
[jobs.jobs]
|
[jobs.jobs]
|
||||||
);
|
);
|
||||||
|
const unattemptedRowsOnPage = useMemo(() => jobs.jobs.filter(row => reportUnattempted(row) && String(row.id ?? "")), [jobs.jobs]);
|
||||||
|
|
||||||
async function retryFailedSynchronously(rows: Record<string, unknown>[]) {
|
async function runInlineRecovery(action: CampaignInlineRecoveryAction, rows: Record<string, unknown>[]) {
|
||||||
if (!version || busyAction || rows.length === 0) return;
|
if (!version || busyAction || pendingAction.current || !canSend || (action === "retry" ? !canRetry : !canQueue) || rows.length === 0) return;
|
||||||
setBusyAction(rows.length === 1 ? `retry-sync:${String(rows[0].id ?? "")}` : "retry-sync-page");
|
const jobIds = [...new Set(rows.map(row => String(row.id ?? "")).filter(Boolean))];
|
||||||
|
if (!jobIds.length) return;
|
||||||
|
pendingAction.current = true;
|
||||||
|
setBusyAction(`inline:${action}`);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
setActionMessage("");
|
setActionMessage("");
|
||||||
let attempted = 0;
|
const versionId = version.id;
|
||||||
let accepted = 0;
|
setProgress({ versionId, requestState: "running" });
|
||||||
let skipped = 0;
|
|
||||||
const failures: string[] = [];
|
|
||||||
try {
|
try {
|
||||||
for (const row of rows) {
|
const result = await runCampaignInlineRecovery(settings, campaignId, action, {
|
||||||
const jobId = String(row.id ?? "");
|
version_id: versionId, job_ids: jobIds,
|
||||||
if (!jobId) {
|
...(action === "retry" ? { include_permanent: rows.some(row => row.send_status === "failed_permanent") } : {})
|
||||||
skipped += 1;
|
});
|
||||||
continue;
|
if (reportContext !== reportContextRef.current) return;
|
||||||
}
|
setProgress({ versionId, requestState: "finished" });
|
||||||
const sendStatus = String(row.send_status ?? "");
|
setActionMessage(i18nMessage("i18n:govoplan-campaign.report_inline_result", {
|
||||||
const queueResponse = await retryCampaignJobs(settings, campaignId, {
|
value0: result.attempted_count ?? 0, value1: result.sent_count ?? 0,
|
||||||
version_id: version.id,
|
value2: result.failed_count ?? 0, value3: result.outcome_unknown_count ?? 0, value4: result.remaining_count ?? 0
|
||||||
job_ids: [jobId],
|
}));
|
||||||
include_permanent: sendStatus === "failed_permanent",
|
try { await reloadAll(); }
|
||||||
enqueue_celery: false
|
catch { if (reportContext === reportContextRef.current) setActionError("i18n:govoplan-campaign.report_acknowledged_refresh_failed"); }
|
||||||
});
|
} catch (cause) {
|
||||||
const queueResult = asRecord(queueResponse.result ?? queueResponse);
|
if (reportContext !== reportContextRef.current) return;
|
||||||
if (Number(queueResult.selected_count ?? 0) < 1) {
|
const message = cause instanceof Error ? cause.message : String(cause);
|
||||||
skipped += 1;
|
setActionError(message);
|
||||||
const skippedRows = Array.isArray(queueResult.skipped) ? queueResult.skipped.map(asRecord) : [];
|
setProgress({ versionId, requestState: "interrupted", requestError: message });
|
||||||
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, {
|
|
||||||
kind: "single_resend",
|
|
||||||
idempotency_key: crypto.randomUUID(),
|
|
||||||
reason: "Operator requested synchronous resend from the campaign report.",
|
|
||||||
include_warnings: true,
|
|
||||||
use_rate_limit: true,
|
|
||||||
enqueue_imap_task: false
|
|
||||||
});
|
|
||||||
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
|
|
||||||
const status = String(sendResult.status ?? "submitted");
|
|
||||||
if (["smtp_accepted", "postbox_accepted", "print_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) 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 {
|
} finally {
|
||||||
setBusyAction("");
|
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reconcileOutcome() {
|
async function reconcileOutcome(note: string) {
|
||||||
if (!reconcile || busyAction) return;
|
if (!reconcile || busyAction || pendingAction.current || !canReconcile || !note.trim()) return;
|
||||||
|
pendingAction.current = true;
|
||||||
setBusyAction("reconcile");
|
setBusyAction("reconcile");
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision);
|
if (reconcile.kind === "claim") {
|
||||||
setActionMessage(reconcile.decision === "smtp_accepted" ?
|
await recoverCampaignJobClaim(settings, campaignId, reconcile.jobId, { channel: reconcile.channel, expected_revision: reconcile.revision, note });
|
||||||
"i18n:govoplan-campaign.the_job_was_recorded_as_smtp_accepted_and_is_pro.12ee72b6" :
|
} else {
|
||||||
"i18n:govoplan-campaign.the_job_was_recorded_as_not_sent_it_is_now_an_ex.2cea8409");
|
await resolveCampaignJobOutcome(settings, campaignId, reconcile.jobId, reconcile.decision, note);
|
||||||
|
}
|
||||||
|
if (reportContext !== reportContextRef.current) return;
|
||||||
|
setActionMessage(reconcile.kind === "claim" ? "i18n:govoplan-campaign.report_claim_recovered" : "i18n:govoplan-campaign.report_evidence_recorded");
|
||||||
setReconcile(null);
|
setReconcile(null);
|
||||||
await reloadAll();
|
try { await reloadAll(); }
|
||||||
} catch (err) {
|
catch { if (reportContext === reportContextRef.current) setActionError("i18n:govoplan-campaign.report_acknowledged_refresh_failed"); }
|
||||||
setActionError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusyAction("");
|
if (reportContext === reportContextRef.current) { pendingAction.current = false; setBusyAction(""); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,11 +303,12 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
setBusyAction("detail");
|
setBusyAction("detail");
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
setDetail(await getCampaignJobDetail(settings, campaignId, jobId));
|
const response = await getCampaignJobDetail(settings, campaignId, jobId);
|
||||||
|
if (reportContext === reportContextRef.current) setDetail(response);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err instanceof Error ? err.message : String(err));
|
if (reportContext === reportContextRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setBusyAction("");
|
if (reportContext === reportContextRef.current) setBusyAction("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,13 +357,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
resizable: true,
|
resizable: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
render: (row) =>
|
render: (row) => <ReportRecipients row={row} />,
|
||||||
<div className="recipient-outcome-cell">
|
value: reportRecipientSearchText
|
||||||
<strong>{String(row.recipient_email ?? "—")}</strong>
|
|
||||||
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
|
|
||||||
</div>,
|
|
||||||
|
|
||||||
value: (row) => String(row.recipient_email ?? "—")
|
|
||||||
},
|
},
|
||||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||||
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: VALIDATION_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: VALIDATION_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||||
@@ -422,7 +398,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
width: "minmax(220px, 1fr)",
|
width: "minmax(220px, 1fr)",
|
||||||
maxWidth: 720,
|
maxWidth: 720,
|
||||||
resizable: true,
|
resizable: true,
|
||||||
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
|
render: (row) => <div className="recipient-outcome-cell"><span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>
|
||||||
|
{reportRecoveryHints(row).map(hint => <span key={hint}>{hint}</span>)}</div>,
|
||||||
value: (row) => String(row.last_error ?? "—")
|
value: (row) => String(row.last_error ?? "—")
|
||||||
},
|
},
|
||||||
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 165, sortable: true, value: (row) => formatDateTime(String(row.updated_at ?? row.sent_at ?? row.queued_at ?? "")) },
|
{ id: "updated", header: "i18n:govoplan-campaign.updated.f2f8570d", width: 165, sortable: true, value: (row) => formatDateTime(String(row.updated_at ?? row.sent_at ?? row.queued_at ?? "")) },
|
||||||
@@ -434,16 +411,23 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
render: (row) => {
|
render: (row) => {
|
||||||
const id = String(row.id ?? "");
|
const id = String(row.id ?? "");
|
||||||
const status = String(row.send_status ?? "");
|
const status = String(row.send_status ?? "");
|
||||||
|
const smtpClaim = reportClaimRecovery(row, "smtp");
|
||||||
|
const imapClaim = reportClaimRecovery(row, "imap");
|
||||||
return <TableActionGroup actions={[
|
return <TableActionGroup actions={[
|
||||||
{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
|
{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
|
||||||
{ id: "retry", label: busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now", icon: <RotateCcw aria-hidden="true" />, applicable: retryableFailedStatus(status), disabled: !id || Boolean(busyAction), onClick: () => void retryFailedSynchronously([row]) },
|
{ id: "retry", label: "i18n:govoplan-campaign.report_retry_now", icon: <RotateCcw aria-hidden="true" />, applicable: reportRetryableFailure(row), disabled: !id || !canRetry || !canSend || Boolean(busyAction), onClick: () => void runInlineRecovery("retry", [row]) },
|
||||||
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
|
{ id: "unattempted", label: "i18n:govoplan-campaign.report_send_unattempted_now", icon: <RotateCcw aria-hidden="true" />, applicable: reportUnattempted(row), disabled: !id || !canQueue || !canSend || Boolean(busyAction), onClick: () => void runInlineRecovery("send-unattempted", [row]) },
|
||||||
{ id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "not_sent" }) }
|
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "smtp_accepted" }) },
|
||||||
|
{ id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "not_sent" }) },
|
||||||
|
{ id: "recover-smtp", label: "i18n:govoplan-campaign.report_recover_smtp_claim", icon: <RotateCcw aria-hidden="true" />, applicable: smtpClaim.eligible, disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "claim", jobId: id, channel: "smtp", revision: smtpClaim.revision }) },
|
||||||
|
{ id: "recover-imap", label: "i18n:govoplan-campaign.report_recover_imap_claim", icon: <RotateCcw aria-hidden="true" />, applicable: imapClaim.eligible, disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "claim", jobId: id, channel: "imap", revision: imapClaim.revision }) },
|
||||||
|
{ id: "imap-appended", label: "i18n:govoplan-campaign.report_record_imap_appended", icon: <Check aria-hidden="true" />, applicable: row.imap_status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "imap_appended" }) },
|
||||||
|
{ id: "imap-not-appended", label: "i18n:govoplan-campaign.report_record_imap_not_appended", icon: <X aria-hidden="true" />, variant: "danger", applicable: row.imap_status === "outcome_unknown", disabled: !canReconcile || Boolean(busyAction), onClick: () => setReconcile({ kind: "outcome", jobId: id, decision: "imap_not_appended" }) }
|
||||||
]} />;
|
]} />;
|
||||||
|
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
[busyAction, retryFailedSynchronously]);
|
[busyAction, canRetry, canSend, canQueue, canReconcile, runInlineRecovery]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
@@ -457,7 +441,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
actions={<PageActionBar
|
actions={<PageActionBar
|
||||||
variant="detail"
|
variant="detail"
|
||||||
refreshable
|
refreshable
|
||||||
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading }}
|
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading, disabled: Boolean(busyAction) }}
|
||||||
primaryActions={<>
|
primaryActions={<>
|
||||||
<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>
|
||||||
@@ -533,12 +517,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
||||||
<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 || !canRetry || !workersAvailable || Boolean(busyAction)}>i18n:govoplan-campaign.report_queue_retry_workers</Button>
|
||||||
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
<Button onClick={() => void runInlineRecovery("retry", failedRowsOnPage)} disabled={!version || !canRetry || !canSend || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
||||||
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
|
{i18nMessage("i18n:govoplan-campaign.report_retry_page_now", { value0: failedRowsOnPage.length })}
|
||||||
|
</Button>
|
||||||
|
<Button helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void runExplicitAction("unattempted")} disabled={!version || !canQueue || !workersAvailable || Boolean(busyAction)}>i18n:govoplan-campaign.report_queue_unattempted_workers</Button>
|
||||||
|
<Button onClick={() => void runInlineRecovery("send-unattempted", unattemptedRowsOnPage)} disabled={!version || !canQueue || !canSend || Boolean(busyAction) || unattemptedRowsOnPage.length === 0}>
|
||||||
|
{i18nMessage("i18n:govoplan-campaign.report_send_page_now", { value0: unattemptedRowsOnPage.length })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!workersAvailable && <p className="muted">i18n:govoplan-campaign.report_workers_unavailable</p>}
|
||||||
|
<p className="muted">i18n:govoplan-campaign.report_inline_scope_help</p>
|
||||||
</Card>
|
</Card>
|
||||||
</ContentGrid>
|
</ContentGrid>
|
||||||
|
|
||||||
@@ -612,7 +601,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
{detail &&
|
{detail &&
|
||||||
<div className="stacked-sections">
|
<div className="stacked-sections">
|
||||||
<DescriptionList variant="inline">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.recipients.78cbf8eb</dt><dd><ReportRecipients row={detail.job} /></dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
|
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
|
||||||
@@ -640,17 +629,11 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
}
|
}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
{reconcile && <ReportRecoveryDialog key={`${reconcile.jobId}-${reconcile.kind}-${reconcile.kind === "claim" ? reconcile.channel : reconcile.decision}`}
|
||||||
open={Boolean(reconcile)}
|
request={reconcile} onConfirm={reconcileOutcome} onClose={() => setReconcile(null)} />}
|
||||||
title={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_smtp_acceptance.c40f8c9d" : "i18n:govoplan-campaign.record_message_as_not_sent.42e4faf8"}
|
{progress && <CampaignDeliveryProgressDialog settings={settings} campaignId={campaignId} versionId={progress.versionId}
|
||||||
message={reconcile?.decision === "smtp_accepted" ?
|
kind="smtp" requestState={progress.requestState} requestError={progress.requestError}
|
||||||
"i18n:govoplan-campaign.use_this_only_after_checking_the_smtp_server_or_.6f4396e1" :
|
onClose={() => { if (!pendingAction.current) setProgress(null); }} />}
|
||||||
"i18n:govoplan-campaign.use_this_only_when_you_have_evidence_that_smtp_d.aa48f4ad"}
|
|
||||||
confirmLabel={reconcile?.decision === "smtp_accepted" ? "i18n:govoplan-campaign.record_accepted.023d6747" : "i18n:govoplan-campaign.record_not_sent.b376b4ed"}
|
|
||||||
tone={reconcile?.decision === "smtp_accepted" ? "default" : "danger"}
|
|
||||||
busy={busyAction === "reconcile"}
|
|
||||||
onConfirm={() => void reconcileOutcome()}
|
|
||||||
onCancel={() => setReconcile(null)} />
|
|
||||||
|
|
||||||
</PageLayout>);
|
</PageLayout>);
|
||||||
|
|
||||||
@@ -1049,10 +1032,6 @@ function initialReportGridFilters(): Record<string, string | string[]> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deliveryStatusLabel(status: string): string | undefined {
|
|
||||||
return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calendarRsvpStatus(row: Record<string, unknown>): string {
|
function calendarRsvpStatus(row: Record<string, unknown>): string {
|
||||||
const invitation = asRecord(row.calendar_invitation);
|
const invitation = asRecord(row.calendar_invitation);
|
||||||
return String(invitation.rsvp_status || "—");
|
return String(invitation.rsvp_status || "—");
|
||||||
@@ -1086,11 +1065,3 @@ function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
|
|||||||
}
|
}
|
||||||
return "number";
|
return "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryableFailedStatus(status: string): boolean {
|
|
||||||
return status === "failed_temporary" || status === "failed_permanent" || status === "partially_accepted";
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortJobId(jobId: string): string {
|
|
||||||
return jobId.length > 12 ? `${jobId.slice(0, 12)}...` : jobId;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
<Route path="policy" element={<Navigate to="../policies" replace />} />
|
<Route path="policy" element={<Navigate to="../policies" replace />} />
|
||||||
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||||
<Route path="send" element={<Navigate to="../review" replace />} />
|
<Route path="send" element={<Navigate to="../review" replace />} />
|
||||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="report" element={<CampaignReportPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||||
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||||
<Route path="work" element={hasScope(auth, "campaigns:assignment:read") ? <CampaignWorkPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
<Route path="work" element={hasScope(auth, "campaigns:assignment:read") ? <CampaignWorkPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
|||||||
import { PolicyRow } from "@govoplan/core-webui";
|
import { PolicyRow } from "@govoplan/core-webui";
|
||||||
import { PolicyTable } from "@govoplan/core-webui";
|
import { PolicyTable } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||||
import CampaignAccessCard from "./components/CampaignAccessCard";
|
import CampaignAccessCard from "./components/CampaignAccessCard";
|
||||||
|
import CampaignArchiveEncryptionPolicyNotice from "./components/CampaignArchiveEncryptionPolicyNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { hasScope } from "@govoplan/core-webui";
|
import { hasScope } from "@govoplan/core-webui";
|
||||||
@@ -63,7 +65,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, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saving, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -183,21 +185,23 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
mode="workspace"
|
mode="workspace"
|
||||||
title={pageTitle}
|
title={pageTitle}
|
||||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
headerLoading={loading}
|
headerLoading={loading || saving}
|
||||||
error={error}
|
error={error}
|
||||||
actions={<PageActionBar
|
actions={<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||||
/>}
|
/>}
|
||||||
notices={(localError || locked) ? <>
|
notices={(localError || locked || version?.mail_profile_migration_required) ? <>
|
||||||
|
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</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" />}
|
{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" />}
|
||||||
</> : undefined}
|
</> : undefined}
|
||||||
>
|
>
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
|
<CampaignArchiveEncryptionPolicyNotice settings={settings} auth={auth} campaignId={campaignId} />
|
||||||
{isPolicyView ?
|
{isPolicyView ?
|
||||||
<>
|
<>
|
||||||
{canReadRetentionPolicy &&
|
{canReadRetentionPolicy &&
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MetricGrid } from "@govoplan/core-webui";
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { FormGrid,
|
import { FormGrid,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -33,10 +33,11 @@ import {
|
|||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||||
import { getBool, getText } from "./utils/draftEditor";
|
import { getBool, getText } from "./utils/draftEditor";
|
||||||
import { campaignMailProfileReferenceOnly } from "./utils/mailProfileReference";
|
import { campaignMailProfileListOptions, campaignMailProfileReferenceOnly } from "./utils/mailProfileReference";
|
||||||
|
|
||||||
type MailSettingsView = "settings" | "policy";
|
type MailSettingsView = "settings" | "policy";
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
|
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
|
||||||
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
|
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
|
||||||
const [profilesLoading, setProfilesLoading] = useState(false);
|
const [profilesLoading, setProfilesLoading] = useState(false);
|
||||||
|
const profileLoadGeneration = useRef(0);
|
||||||
|
const [migrationSaving, setMigrationSaving] = useState(false);
|
||||||
const [profileError, setProfileError] = useState("");
|
const [profileError, setProfileError] = useState("");
|
||||||
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
||||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||||
@@ -81,7 +84,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 migrationRequired = version?.mail_profile_migration_required === true;
|
const migrationRequired = version?.mail_profile_migration_required === true;
|
||||||
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, displayDraft, dirty, saving, saveState, localError, setLocalError, patch, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -104,30 +107,28 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||||
const smtpServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "smtp" && item.is_active);
|
const smtpServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "smtp" && item.is_active);
|
||||||
const imapServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "imap" && item.is_active);
|
const imapServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "imap" && item.is_active);
|
||||||
const selectedSmtpServer = smtpServers.find((item) => item.id === getText(server, "smtp_server_id"))
|
const smtpServerId = getText(server, "smtp_server_id");
|
||||||
?? smtpServers.find((item) => item.is_default)
|
const imapServerId = getText(server, "imap_server_id");
|
||||||
?? smtpServers[0]
|
const selectedSmtpServer = (smtpServerId
|
||||||
?? null;
|
? smtpServers.find((item) => item.id === smtpServerId)
|
||||||
const selectedImapServer = imapServers.find((item) => item.id === getText(server, "imap_server_id"))
|
: smtpServers.find((item) => item.is_default) ?? smtpServers[0]) ?? null;
|
||||||
?? imapServers.find((item) => item.is_default)
|
const selectedImapServer = (imapServerId
|
||||||
?? imapServers[0]
|
? imapServers.find((item) => item.id === imapServerId)
|
||||||
?? null;
|
: imapServers.find((item) => item.is_default) ?? imapServers[0]) ?? null;
|
||||||
const selectedSmtpCredential = selectedSmtpServer?.credentials.find((item) => item.id === getText(server, "smtp_credential_id"))
|
const selectedSmtpCredential = selectedSmtpServer?.credentials.find((item) => item.id === getText(server, "smtp_credential_id"))
|
||||||
?? selectedSmtpServer?.credentials.find((item) => item.is_default)
|
|
||||||
?? selectedSmtpServer?.credentials[0]
|
|
||||||
?? null;
|
?? null;
|
||||||
const selectedImapCredential = selectedImapServer?.credentials.find((item) => item.id === getText(server, "imap_credential_id"))
|
const selectedImapCredential = selectedImapServer?.credentials.find((item) => item.id === getText(server, "imap_credential_id"))
|
||||||
?? selectedImapServer?.credentials.find((item) => item.is_default)
|
|
||||||
?? selectedImapServer?.credentials[0]
|
|
||||||
?? null;
|
?? null;
|
||||||
const delivery = asRecord(displayDraft.delivery);
|
const delivery = asRecord(displayDraft.delivery);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||||
const selectedProfileHasImap = Boolean(selectedImapServer);
|
const selectedProfileHasImap = Boolean(selectedImapServer);
|
||||||
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
|
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
|
||||||
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
|
const canMigrate = migrationRequired && !locked && !saving && Boolean(draft) && Boolean(selectedProfile) && !profilesLoading;
|
||||||
|
const canSave = dirty && !locked && !saving && !migrationSaving && Boolean(draft) && (!migrationRequired || canMigrate);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
profileLoadGeneration.current += 1;
|
||||||
if (!mailModuleInstalled) {
|
if (!mailModuleInstalled) {
|
||||||
setMailProfiles([]);
|
setMailProfiles([]);
|
||||||
setPolicyProfiles([]);
|
setPolicyProfiles([]);
|
||||||
@@ -135,25 +136,41 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshMailProfiles();
|
void refreshMailProfiles();
|
||||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, mailModuleInstalled]);
|
return () => {
|
||||||
|
profileLoadGeneration.current += 1;
|
||||||
|
};
|
||||||
|
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, mailModuleInstalled, view]);
|
||||||
|
|
||||||
async function refreshMailProfiles() {
|
async function refreshMailProfiles() {
|
||||||
if (!mailModuleInstalled) return;
|
if (!mailModuleInstalled) return;
|
||||||
|
const generation = ++profileLoadGeneration.current;
|
||||||
setProfilesLoading(true);
|
setProfilesLoading(true);
|
||||||
setProfileError("");
|
setProfileError("");
|
||||||
try {
|
try {
|
||||||
const [allowedProfiles, visibleProfiles] = await Promise.all([
|
const options = campaignMailProfileListOptions(view, campaignId);
|
||||||
listMailServerProfiles(settings, false, campaignId),
|
const profiles = await listMailServerProfiles(settings, options.includeInactive, options.campaignId);
|
||||||
listMailServerProfiles(settings, true)
|
if (generation !== profileLoadGeneration.current) return;
|
||||||
]);
|
if (isPolicyView) setPolicyProfiles(profiles);
|
||||||
setMailProfiles(allowedProfiles);
|
else setMailProfiles(profiles);
|
||||||
setPolicyProfiles(visibleProfiles);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMailProfiles([]);
|
if (generation !== profileLoadGeneration.current) return;
|
||||||
setPolicyProfiles([]);
|
if (isPolicyView) setPolicyProfiles([]);
|
||||||
|
else setMailProfiles([]);
|
||||||
setProfileError(err instanceof Error ? err.message : String(err));
|
setProfileError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setProfilesLoading(false);
|
if (generation === profileLoadGeneration.current) setProfilesLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateMailProfile() {
|
||||||
|
if (!canMigrate || migrationSaving) return;
|
||||||
|
setMigrationSaving(true);
|
||||||
|
try {
|
||||||
|
// Explicit consent is needed even when the existing public profile
|
||||||
|
// reference is unchanged, so this action deliberately works on a clean draft.
|
||||||
|
await saveDraft("manual");
|
||||||
|
} finally {
|
||||||
|
setMigrationSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,10 +207,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
?? null;
|
?? null;
|
||||||
patch(["server"], mailReference(
|
patch(["server"], mailReference(
|
||||||
selectedProfile.id,
|
selectedProfile.id,
|
||||||
protocol === "smtp" ? selected?.id : selectedSmtpServer?.id,
|
protocol === "smtp" ? selected?.id : getText(server, "smtp_server_id"),
|
||||||
protocol === "smtp" ? credential?.id : selectedSmtpCredential?.id,
|
protocol === "smtp" ? credential?.id : getText(server, "smtp_credential_id"),
|
||||||
protocol === "imap" ? selected?.id : selectedImapServer?.id,
|
protocol === "imap" ? selected?.id : getText(server, "imap_server_id"),
|
||||||
protocol === "imap" ? credential?.id : selectedImapCredential?.id
|
protocol === "imap" ? credential?.id : getText(server, "imap_credential_id")
|
||||||
));
|
));
|
||||||
if (protocol === "smtp") setSmtpTestResult(null);
|
if (protocol === "smtp") setSmtpTestResult(null);
|
||||||
else {
|
else {
|
||||||
@@ -206,10 +223,10 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
if (!selectedProfile || locked) return;
|
if (!selectedProfile || locked) return;
|
||||||
patch(["server"], mailReference(
|
patch(["server"], mailReference(
|
||||||
selectedProfile.id,
|
selectedProfile.id,
|
||||||
selectedSmtpServer?.id,
|
protocol === "smtp" ? selectedSmtpServer?.id : getText(server, "smtp_server_id"),
|
||||||
protocol === "smtp" ? credentialId : selectedSmtpCredential?.id,
|
protocol === "smtp" ? credentialId : getText(server, "smtp_credential_id"),
|
||||||
selectedImapServer?.id,
|
protocol === "imap" ? selectedImapServer?.id : getText(server, "imap_server_id"),
|
||||||
protocol === "imap" ? credentialId : selectedImapCredential?.id
|
protocol === "imap" ? credentialId : getText(server, "imap_credential_id")
|
||||||
));
|
));
|
||||||
if (protocol === "smtp") setSmtpTestResult(null);
|
if (protocol === "smtp") setSmtpTestResult(null);
|
||||||
else {
|
else {
|
||||||
@@ -282,7 +299,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
settings,
|
settings,
|
||||||
selectedProfileId,
|
selectedProfileId,
|
||||||
selectedSmtpServer?.id,
|
selectedSmtpServer?.id,
|
||||||
selectedSmtpCredential?.id,
|
getText(server, "smtp_credential_id") || undefined,
|
||||||
campaignId
|
campaignId
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
@@ -290,7 +307,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
settings,
|
settings,
|
||||||
selectedProfileId,
|
selectedProfileId,
|
||||||
selectedImapServer?.id,
|
selectedImapServer?.id,
|
||||||
selectedImapCredential?.id,
|
getText(server, "imap_credential_id") || undefined,
|
||||||
campaignId
|
campaignId
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -312,7 +329,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
settings,
|
settings,
|
||||||
selectedProfileId,
|
selectedProfileId,
|
||||||
selectedImapServer?.id,
|
selectedImapServer?.id,
|
||||||
selectedImapCredential?.id,
|
getText(server, "imap_credential_id") || undefined,
|
||||||
campaignId
|
campaignId
|
||||||
));
|
));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -333,7 +350,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
mode="workspace"
|
mode="workspace"
|
||||||
title={isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}
|
title={isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}
|
||||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
headerLoading={loading}
|
headerLoading={loading || migrationSaving || saving}
|
||||||
error={error}
|
error={error}
|
||||||
actions={isPolicyView ? (
|
actions={isPolicyView ? (
|
||||||
<PageActionBar
|
<PageActionBar
|
||||||
@@ -344,7 +361,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
) : (
|
) : (
|
||||||
<PageActionBar
|
<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
state={loading || migrationSaving || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current mail-settings blocker before saving." : undefined }}
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current mail-settings blocker before saving." : undefined }}
|
||||||
/>
|
/>
|
||||||
@@ -359,9 +376,16 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
<>
|
<>
|
||||||
{!mailModuleInstalled && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.install_and_enable_the_mail_module_to_select_a_d.01c75fc4</DismissibleAlert>}
|
{!mailModuleInstalled && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.install_and_enable_the_mail_module_to_select_a_d.01c75fc4</DismissibleAlert>}
|
||||||
|
|
||||||
{migrationRequired && <DismissibleAlert tone="warning" dismissible={false}>
|
{migrationRequired && <LegacyMailMigrationNotice
|
||||||
i18n:govoplan-campaign.this_version_contains_legacy_campaign_local_mail.44c7a6fd
|
campaignId={campaignId}
|
||||||
</DismissibleAlert>}
|
versionId={version?.id}
|
||||||
|
showSettingsLink={isPolicyView}
|
||||||
|
onMigrate={!isPolicyView && !locked ? () => void migrateMailProfile() : undefined}
|
||||||
|
canMigrate={canMigrate}
|
||||||
|
busy={migrationSaving}
|
||||||
|
/>}
|
||||||
|
|
||||||
|
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} dismissible={false}>{profileError}</DismissibleAlert>}
|
||||||
|
|
||||||
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && <MailProfilePolicyEditor
|
{isPolicyView && mailModuleInstalled && MailProfilePolicyEditor && <MailProfilePolicyEditor
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@@ -390,38 +414,44 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||||
<option value="">i18n:govoplan-campaign.select_a_mail_profile.76480af0</option>
|
<option value="">i18n:govoplan-campaign.select_a_mail_profile.76480af0</option>
|
||||||
|
{selectedProfileId && !selectedProfile && <option value={selectedProfileId}>i18n:govoplan-campaign.unavailable_selected_profile</option>}
|
||||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="SMTP server">
|
<FormField label="SMTP server">
|
||||||
<select value={selectedSmtpServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("smtp", event.target.value)}>
|
<select value={smtpServerId} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("smtp", event.target.value)}>
|
||||||
<option value="">No SMTP server</option>
|
<option value="">i18n:govoplan-campaign.inherit_mail_server</option>
|
||||||
|
{smtpServerId && !selectedSmtpServer && <option value={smtpServerId}>i18n:govoplan-campaign.unavailable_selected_server</option>}
|
||||||
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="SMTP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
<FormField label="SMTP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
||||||
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
|
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={getText(server, "smtp_credential_id")} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
|
||||||
<option value="">No credential</option>
|
<option value="">i18n:govoplan-campaign.inherit_mail_credential_when_allowed</option>
|
||||||
|
{getText(server, "smtp_credential_id") && !selectedSmtpServer?.credentials.some((item) => item.is_active && item.id === getText(server, "smtp_credential_id")) && <option value={getText(server, "smtp_credential_id")}>i18n:govoplan-campaign.unavailable_selected_credential</option>}
|
||||||
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
|
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="IMAP server">
|
<FormField label="IMAP server">
|
||||||
<select value={selectedImapServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("imap", event.target.value)}>
|
<select value={imapServerId} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("imap", event.target.value)}>
|
||||||
<option value="">No IMAP server</option>
|
<option value="">i18n:govoplan-campaign.inherit_mail_server</option>
|
||||||
|
{imapServerId && !selectedImapServer && <option value={imapServerId}>i18n:govoplan-campaign.unavailable_selected_server</option>}
|
||||||
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="IMAP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
<FormField label="IMAP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
||||||
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
|
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={getText(server, "imap_credential_id")} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
|
||||||
<option value="">No credential</option>
|
<option value="">i18n:govoplan-campaign.inherit_mail_credential_when_allowed</option>
|
||||||
|
{getText(server, "imap_credential_id") && !selectedImapServer?.credentials.some((item) => item.is_active && item.id === getText(server, "imap_credential_id")) && <option value={getText(server, "imap_credential_id")}>i18n:govoplan-campaign.unavailable_selected_credential</option>}
|
||||||
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
|
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
|
<p className="muted small-note">i18n:govoplan-campaign.explicit_mail_credential_help</p>
|
||||||
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
||||||
{selectedProfile && <MetricGrid spacing="inset">
|
{selectedProfile && <MetricGrid spacing="inset">
|
||||||
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
||||||
@@ -435,7 +465,6 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
</div>
|
</div>
|
||||||
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
|
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
|
||||||
{imapTestResult && <DismissibleAlert tone={imapTestResult.ok ? "success" : "danger"} resetKey={`${imapTestResult.protocol}:${imapTestResult.message}`} floating>{imapTestResult.message}</DismissibleAlert>}
|
{imapTestResult && <DismissibleAlert tone={imapTestResult.ok ? "success" : "danger"} resetKey={`${imapTestResult.protocol}:${imapTestResult.message}`} floating>{imapTestResult.message}</DismissibleAlert>}
|
||||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
<Dialog variant="administration" size="wide"
|
<Dialog variant="administration" size="wide"
|
||||||
@@ -541,8 +570,8 @@ function mailReference(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serverEndpointLabel(config: MailServerEndpoint["config"]): string {
|
function serverEndpointLabel(config: MailServerEndpoint["config"]): string {
|
||||||
const host = typeof config.host === "string" && config.host ? config.host : "No host";
|
const host = "host" in config && typeof config.host === "string" && config.host ? config.host : "No host";
|
||||||
return config.port ? `${host}:${config.port}` : host;
|
return "port" in config && config.port ? `${host}:${config.port}` : host;
|
||||||
}
|
}
|
||||||
|
|
||||||
function credentialLabel(credential: MailCredentialEnvelope): string {
|
function credentialLabel(credential: MailCredentialEnvelope): string {
|
||||||
|
|||||||
@@ -53,14 +53,13 @@ import {
|
|||||||
AddressHeaderControl,
|
AddressHeaderControl,
|
||||||
HeaderAddressEditorDialog,
|
HeaderAddressEditorDialog,
|
||||||
RecipientAddressEditorDialog,
|
RecipientAddressEditorDialog,
|
||||||
entryWithAddressList,
|
entryWithAddressValues,
|
||||||
formatAddressCollectionForClipboard,
|
formatAddressCollectionForClipboard,
|
||||||
getAddressColumn,
|
getAddressColumn,
|
||||||
getEntryAddresses,
|
getEntryAddresses,
|
||||||
headerAddressValues,
|
headerAddressValues,
|
||||||
hiddenRecipientAddressMatch,
|
hiddenRecipientAddressMatch,
|
||||||
recipientAddressFilterValue,
|
recipientAddressFilterValue,
|
||||||
recipientAddressOverlayColumns,
|
|
||||||
recipientAddressSummary,
|
recipientAddressSummary,
|
||||||
recipientHeaderRows,
|
recipientHeaderRows,
|
||||||
type AddressFieldKey,
|
type AddressFieldKey,
|
||||||
@@ -110,7 +109,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|||||||
|
|
||||||
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, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saving, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -293,17 +292,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|||||||
values: HeaderAddressValues,
|
values: HeaderAddressValues,
|
||||||
merges: EntryAddressMergeValues
|
merges: EntryAddressMergeValues
|
||||||
) {
|
) {
|
||||||
updateEntry(index, (entry) => {
|
updateEntry(index, (entry) => entryWithAddressValues(entry, values, merges));
|
||||||
let nextEntry = entry;
|
|
||||||
for (const column of recipientAddressOverlayColumns) {
|
|
||||||
if (!(column.key in values)) continue;
|
|
||||||
nextEntry = entryWithAddressList(nextEntry, column.key, values[column.key] ?? []);
|
|
||||||
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
|
|
||||||
nextEntry = { ...nextEntry, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
|
|
||||||
delete nextEntry[column.mergeKey.replace("merge_", "combine_")];
|
|
||||||
}
|
|
||||||
return nextEntry;
|
|
||||||
});
|
|
||||||
setRecipientAddressEditorIndex(null);
|
setRecipientAddressEditorIndex(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,6 +406,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|||||||
campaignId={campaignId}
|
campaignId={campaignId}
|
||||||
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
|
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
saving={saving}
|
||||||
version={version}
|
version={version}
|
||||||
versions={data.versions}
|
versions={data.versions}
|
||||||
saveState={saveState}
|
saveState={saveState}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
|||||||
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
|
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
|
import LegacyMailMigrationNotice from "./components/LegacyMailMigrationNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||||
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
|
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
|
||||||
@@ -79,7 +80,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, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saving, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -478,17 +479,18 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
mode="workspace"
|
mode="workspace"
|
||||||
title="i18n:govoplan-campaign.template.3ec1ae06"
|
title="i18n:govoplan-campaign.template.3ec1ae06"
|
||||||
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
headerLoading={loading}
|
headerLoading={loading || saving}
|
||||||
error={error}
|
error={error}
|
||||||
success={contentLibraryNotice}
|
success={contentLibraryNotice}
|
||||||
actions={<PageActionBar
|
actions={<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
contextActions={<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>}
|
contextActions={<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>}
|
||||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||||
/>}
|
/>}
|
||||||
notices={(localError || locked) ? <>
|
notices={(localError || locked || version?.mail_profile_migration_required) ? <>
|
||||||
|
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</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" />}
|
{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" />}
|
||||||
</> : undefined}
|
</> : undefined}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog, DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
|
import { usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesManagedAttachmentSelection } from "@govoplan/core-webui";
|
||||||
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
@@ -161,6 +161,7 @@ export function AttachmentRulesDataGrid({
|
|||||||
onChange
|
onChange
|
||||||
}: AttachmentRulesTableProps) {
|
}: AttachmentRulesTableProps) {
|
||||||
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
const [fileChooser, setFileChooser] = useState<FileChooserState | null>(null);
|
||||||
|
const [chooserError, setChooserError] = useState("");
|
||||||
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||||
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
|
const ManagedFileChooser = filesModuleInstalled ? filesFileExplorer?.ManagedFileChooser : null;
|
||||||
const managedFilesAvailable = Boolean(ManagedFileChooser);
|
const managedFilesAvailable = Boolean(ManagedFileChooser);
|
||||||
@@ -187,7 +188,12 @@ export function AttachmentRulesDataGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFileChooser(ruleIndex: number) {
|
function openFileChooser(ruleIndex: number) {
|
||||||
if (!managedFilesAvailable) return;
|
if (disabled) return;
|
||||||
|
setChooserError("");
|
||||||
|
if (!managedFilesAvailable) {
|
||||||
|
setChooserError("i18n:govoplan-campaign.file_chooser_unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (onOpenFileChooser) {
|
if (onOpenFileChooser) {
|
||||||
onOpenFileChooser(ruleIndex);
|
onOpenFileChooser(ruleIndex);
|
||||||
return;
|
return;
|
||||||
@@ -200,7 +206,10 @@ export function AttachmentRulesDataGrid({
|
|||||||
basePaths.find((item) => item.path === currentPath) ?? (
|
basePaths.find((item) => item.path === currentPath) ?? (
|
||||||
!explicitlyReferenced ? basePaths[0] : undefined) ??
|
!explicitlyReferenced ? basePaths[0] : undefined) ??
|
||||||
null;
|
null;
|
||||||
if (!basePath) return;
|
if (!basePath) {
|
||||||
|
setChooserError("i18n:govoplan-campaign.choose_available_attachment_source");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setFileChooser({ ruleIndex, basePath });
|
setFileChooser({ ruleIndex, basePath });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,10 +229,11 @@ export function AttachmentRulesDataGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{(chooserError || filesModuleInstalled && !managedFilesAvailable) && <DismissibleAlert tone="warning" dismissible={false}>{chooserError || "i18n:govoplan-campaign.file_chooser_unavailable"}</DismissibleAlert>}
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={id}
|
id={id}
|
||||||
rows={rules}
|
rows={rules}
|
||||||
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled: managedFilesAvailable, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
columns={attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesModuleInstalled, activeChooserRuleIndex: activeChooserRuleIndex ?? fileChooser?.ruleIndex ?? null, patchRule, addRule, moveRule, openFileChooser, removeRule })}
|
||||||
getRowKey={(rule, index) => String(rule.id ?? index)}
|
getRowKey={(rule, index) => String(rule.id ?? index)}
|
||||||
emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
|
emptyText={basePaths.length === 0 ? "i18n:govoplan-campaign.no_attachment_source_is_enabled_for_individual_a.818a2820" : emptyText}
|
||||||
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
|
emptyAction={<DataGridEmptyAction onAdd={() => addRule(-1)} disabled={disabled || basePaths.length === 0} label="i18n:govoplan-campaign.add_first_attachment.025fbf31" />}
|
||||||
@@ -242,7 +252,7 @@ export function AttachmentRulesDataGrid({
|
|||||||
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
|
||||||
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
|
||||||
previewContext={previewContext}
|
previewContext={previewContext}
|
||||||
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context, false)}
|
renderPatternPreview={(pattern, context) => renderTemplatePreviewText(pattern, context ?? {}, false)}
|
||||||
onClose={() => setFileChooser(null)}
|
onClose={() => setFileChooser(null)}
|
||||||
onSelectAttachment={selectAttachment} />
|
onSelectAttachment={selectAttachment} />
|
||||||
|
|
||||||
@@ -316,7 +326,7 @@ function attachmentRuleColumns({ disabled, rules, basePaths, zipConfig, filesMod
|
|||||||
value={getText(rule, "file_filter")}
|
value={getText(rule, "file_filter")}
|
||||||
disabled={disabled || basePaths.length === 0}
|
disabled={disabled || basePaths.length === 0}
|
||||||
readOnly={filesModuleInstalled}
|
readOnly={filesModuleInstalled}
|
||||||
tabIndex={filesModuleInstalled ? -1 : undefined}
|
tabIndex={0}
|
||||||
placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
|
placeholder={filesModuleInstalled ? "i18n:govoplan-campaign.choose_a_managed_file_or_pattern.96bb3bfb" : "file.pdf or **/*.pdf"}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
|
if (!filesModuleInstalled) patchRule(index, { file_filter: event.target.value });
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DismissibleAlert,
|
||||||
|
hasScope,
|
||||||
|
useGuardedNavigate,
|
||||||
|
usePlatformModuleInstalled
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import type { ApiSettings, AuthInfo } from "../../../types";
|
||||||
|
import {
|
||||||
|
getCampaignArchiveEncryptionPolicy,
|
||||||
|
type CampaignArchiveEncryptionPolicy
|
||||||
|
} from "../../../api/campaigns";
|
||||||
|
|
||||||
|
export const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = {
|
||||||
|
available: false,
|
||||||
|
allowed_password_encryption_methods: ["aes"],
|
||||||
|
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
|
||||||
|
policy_hash: "",
|
||||||
|
source_path: [],
|
||||||
|
reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.",
|
||||||
|
diagnostics: [],
|
||||||
|
legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CampaignArchiveEncryptionPolicyNotice({
|
||||||
|
settings,
|
||||||
|
auth,
|
||||||
|
campaignId,
|
||||||
|
onPolicyChange
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
campaignId: string;
|
||||||
|
onPolicyChange?: (policy: CampaignArchiveEncryptionPolicy) => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const policyInstalled = usePlatformModuleInstalled("policy");
|
||||||
|
const [policy, setPolicy] = useState(UNAVAILABLE_ARCHIVE_POLICY);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refresh, setRefresh] = useState(0);
|
||||||
|
const allowed = policy.available && policy.allowed_password_encryption_methods.includes("zip_standard");
|
||||||
|
const canReadPolicy = policyInstalled && hasScope(auth, "admin:policies:read");
|
||||||
|
const canUseLegacy = hasScope(auth, "campaigns:archive:use_legacy_zipcrypto");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setPolicy(UNAVAILABLE_ARCHIVE_POLICY);
|
||||||
|
void getCampaignArchiveEncryptionPolicy(settings, campaignId)
|
||||||
|
.then((loaded) => { if (!cancelled) setPolicy(loaded); })
|
||||||
|
.catch((cause) => {
|
||||||
|
if (!cancelled) setPolicy({
|
||||||
|
...UNAVAILABLE_ARCHIVE_POLICY,
|
||||||
|
reason: cause instanceof Error ? cause.message : String(cause)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [campaignId, refresh, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
|
useEffect(() => { onPolicyChange?.(policy); }, [onPolicyChange, policy]);
|
||||||
|
|
||||||
|
return <DismissibleAlert tone={allowed ? "warning" : "info"} dismissible={false} compact>
|
||||||
|
<strong>{policy.legacy_label}</strong>: {policy.reason}
|
||||||
|
{policy.source_path.length > 0 && <p>{policy.source_path.map((step) => step.label).join(" → ")}</p>}
|
||||||
|
<p>i18n:govoplan-campaign.archive_policy_enable_guidance</p>
|
||||||
|
{!canUseLegacy && <p>i18n:govoplan-campaign.archive_policy_permission_missing</p>}
|
||||||
|
{!policyInstalled && <p>i18n:govoplan-campaign.archive_policy_module_missing</p>}
|
||||||
|
{canReadPolicy && <Button
|
||||||
|
helpContextId="campaign.archive-encryption"
|
||||||
|
helpModuleId="campaigns"
|
||||||
|
onClick={() => navigate("/admin?section=system-campaign-archive-encryption")}
|
||||||
|
>i18n:govoplan-campaign.archive_policy_open_system</Button>}
|
||||||
|
{canReadPolicy && <Button
|
||||||
|
onClick={() => navigate("/admin?section=tenant-campaign-archive-encryption")}
|
||||||
|
>i18n:govoplan-campaign.archive_policy_open_tenant</Button>}
|
||||||
|
<Button disabled={loading} onClick={() => setRefresh((value) => value + 1)}>
|
||||||
|
i18n:govoplan-campaign.archive_policy_reload
|
||||||
|
</Button>
|
||||||
|
</DismissibleAlert>;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { DismissibleAlert, PageActionBar, PageLayout } from "@govoplan/core-webu
|
|||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||||
import LockedVersionNotice from "./LockedVersionNotice";
|
import LockedVersionNotice from "./LockedVersionNotice";
|
||||||
|
import LegacyMailMigrationNotice from "./LegacyMailMigrationNotice";
|
||||||
import VersionLine from "./VersionLine";
|
import VersionLine from "./VersionLine";
|
||||||
|
|
||||||
type CampaignDraftPageScaffoldProps = {
|
type CampaignDraftPageScaffoldProps = {
|
||||||
@@ -10,6 +11,7 @@ type CampaignDraftPageScaffoldProps = {
|
|||||||
campaignId: string;
|
campaignId: string;
|
||||||
title: string;
|
title: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
saving?: boolean;
|
||||||
version: CampaignVersionDetail | null;
|
version: CampaignVersionDetail | null;
|
||||||
versions: CampaignVersionListItem[];
|
versions: CampaignVersionListItem[];
|
||||||
saveState: string;
|
saveState: string;
|
||||||
@@ -30,6 +32,7 @@ export default function CampaignDraftPageScaffold({
|
|||||||
campaignId,
|
campaignId,
|
||||||
title,
|
title,
|
||||||
loading,
|
loading,
|
||||||
|
saving = false,
|
||||||
version,
|
version,
|
||||||
versions,
|
versions,
|
||||||
saveState,
|
saveState,
|
||||||
@@ -54,12 +57,13 @@ export default function CampaignDraftPageScaffold({
|
|||||||
error={error || ""}
|
error={error || ""}
|
||||||
actions={<PageActionBar
|
actions={<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
state={loading || saving ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: onReload }}
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: onReload }}
|
||||||
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: onSave, disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: onSave, disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||||
/>}
|
/>}
|
||||||
notices={(localError || locked) ?
|
notices={(localError || locked || version?.mail_profile_migration_required) ?
|
||||||
<>
|
<>
|
||||||
|
{version?.mail_profile_migration_required && <LegacyMailMigrationNotice campaignId={campaignId} versionId={version.id} />}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</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" />}
|
{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" />}
|
||||||
</> : undefined}
|
</> : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Button, DismissibleAlert, useGuardedNavigate } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
type LegacyMailMigrationNoticeProps = {
|
||||||
|
campaignId: string;
|
||||||
|
versionId?: string;
|
||||||
|
showSettingsLink?: boolean;
|
||||||
|
onMigrate?: () => void;
|
||||||
|
canMigrate?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LegacyMailMigrationNotice({
|
||||||
|
campaignId,
|
||||||
|
versionId,
|
||||||
|
showSettingsLink = true,
|
||||||
|
onMigrate,
|
||||||
|
canMigrate = false,
|
||||||
|
busy = false
|
||||||
|
}: LegacyMailMigrationNoticeProps) {
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
const query = versionId ? `?version=${encodeURIComponent(versionId)}` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DismissibleAlert tone="warning" dismissible={false}>
|
||||||
|
<p>i18n:govoplan-campaign.legacy_mail_migration_required</p>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
{showSettingsLink && <Button
|
||||||
|
onClick={() => navigate(`/campaigns/${encodeURIComponent(campaignId)}/mail-settings${query}`)}
|
||||||
|
disabled={busy}
|
||||||
|
>i18n:govoplan-campaign.open_mail_settings</Button>}
|
||||||
|
{onMigrate && <Button variant="primary" onClick={onMigrate} disabled={!canMigrate || busy}>
|
||||||
|
{busy ? "i18n:govoplan-campaign.migrating_mail_profile" : "i18n:govoplan-campaign.migrate_selected_mail_profile"}
|
||||||
|
</Button>}
|
||||||
|
</div>
|
||||||
|
</DismissibleAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ export type CampaignMessagePreviewOverlayProps = {
|
|||||||
navigation?: CampaignMessagePreviewNavigation;
|
navigation?: CampaignMessagePreviewNavigation;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
closeLabel?: string;
|
closeLabel?: string;
|
||||||
|
closeDisabled?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
navigation,
|
navigation,
|
||||||
actions,
|
actions,
|
||||||
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
closeLabel = "i18n:govoplan-campaign.close.bbfa773e",
|
||||||
|
closeDisabled = false,
|
||||||
onClose
|
onClose
|
||||||
}: CampaignMessagePreviewOverlayProps) {
|
}: CampaignMessagePreviewOverlayProps) {
|
||||||
const shownSubject = subject?.trim() || "i18n:govoplan-campaign.no_subject.7b4e8035";
|
const shownSubject = subject?.trim() || "i18n:govoplan-campaign.no_subject.7b4e8035";
|
||||||
@@ -67,7 +69,7 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
const dialogPanel = contentRef.current?.closest<HTMLElement>("[data-dialog-stack-state]");
|
const dialogPanel = contentRef.current?.closest<HTMLElement>("[data-dialog-stack-state]");
|
||||||
if (dialogPanel?.dataset.dialogStackState !== "topmost") return;
|
if (dialogPanel?.dataset.dialogStackState !== "topmost") return;
|
||||||
if (isEditableTarget(event.target)) return;
|
if (isEditableTarget(event.target)) return;
|
||||||
if (!navigation) return;
|
if (!navigation || closeDisabled) return;
|
||||||
if (event.key === "ArrowLeft") {
|
if (event.key === "ArrowLeft") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (navigation.index > 0) navigation.onPrevious();
|
if (navigation.index > 0) navigation.onPrevious();
|
||||||
@@ -85,7 +87,7 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [navigation]);
|
}, [navigation, closeDisabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -93,6 +95,7 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
title={title}
|
title={title}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
closeLabel={closeLabel}
|
closeLabel={closeLabel}
|
||||||
|
closeDisabled={closeDisabled}
|
||||||
closeOnBackdrop={false}
|
closeOnBackdrop={false}
|
||||||
backdropClassName="overlay-backdrop message-preview-backdrop"
|
backdropClassName="overlay-backdrop message-preview-backdrop"
|
||||||
className="modal-panel template-preview-modal message-preview-modal"
|
className="modal-panel template-preview-modal message-preview-modal"
|
||||||
@@ -101,7 +104,7 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
footerClassName="modal-footer"
|
footerClassName="modal-footer"
|
||||||
footer={<>
|
footer={<>
|
||||||
{actions && <div className="button-row compact-actions">{actions}</div>}
|
{actions && <div className="button-row compact-actions">{actions}</div>}
|
||||||
<Button variant="primary" onClick={onClose}>{closeLabel}</Button>
|
<Button variant="primary" onClick={onClose} disabled={closeDisabled}>{closeLabel}</Button>
|
||||||
</>}
|
</>}
|
||||||
>
|
>
|
||||||
<div ref={contentRef} className="message-preview-content">
|
<div ref={contentRef} className="message-preview-content">
|
||||||
@@ -113,11 +116,11 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
</div>
|
</div>
|
||||||
{navigation &&
|
{navigation &&
|
||||||
<div className="button-row compact-actions template-preview-nav" aria-label="i18n:govoplan-campaign.preview_message_navigation.d28a8dc0">
|
<div className="button-row compact-actions template-preview-nav" aria-label="i18n:govoplan-campaign.preview_message_navigation.d28a8dc0">
|
||||||
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onFirst} disabled={closeDisabled || navigation.index <= 0} title="i18n:govoplan-campaign.first_message.ffc124fd" aria-label="i18n:govoplan-campaign.first_message.ffc124fd"><ArrowBigLeftDash aria-hidden="true" /></button>
|
||||||
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onPrevious} disabled={closeDisabled || navigation.index <= 0} title="i18n:govoplan-campaign.previous_message.93261bd8" aria-label="i18n:govoplan-campaign.previous_message.93261bd8"><ArrowBigLeft aria-hidden="true" /></button>
|
||||||
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
|
<span className="template-preview-count">{navigation.index + 1} / {navigation.total}</span>
|
||||||
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onNext} disabled={closeDisabled || navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.next_message.e3960a5d" aria-label="i18n:govoplan-campaign.next_message.e3960a5d"><ArrowBigRight aria-hidden="true" /></button>
|
||||||
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={closeDisabled || navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</ActionToolbar>
|
</ActionToolbar>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
revisionConflictFromError,
|
revisionConflictFromError,
|
||||||
threeWayMerge,
|
threeWayMerge,
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from "../../../api/campaigns";
|
} from "../../../api/campaigns";
|
||||||
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
|
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
|
||||||
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
|
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
|
||||||
|
import { clientCampaignEditorState } from "../utils/editorState";
|
||||||
|
import { campaignMailReferencesUnchanged } from "../utils/mailProfileReference";
|
||||||
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
|
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
|
||||||
|
|
||||||
type StepValue = string | (() => string | null | undefined);
|
type StepValue = string | (() => string | null | undefined);
|
||||||
@@ -93,6 +95,16 @@ export function useCampaignDraftEditor({
|
|||||||
const baseDraftRef = useRef<Record<string, unknown> | null>(null);
|
const baseDraftRef = useRef<Record<string, unknown> | null>(null);
|
||||||
const baseRevisionRef = useRef<number | null>(null);
|
const baseRevisionRef = useRef<number | null>(null);
|
||||||
const baseEtagRef = useRef<string | null>(null);
|
const baseEtagRef = useRef<string | null>(null);
|
||||||
|
const loadedVersionIdRef = useRef<string | null>(null);
|
||||||
|
const contextKey = JSON.stringify([campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
const contextRef = useRef(contextKey);
|
||||||
|
const loadedContextRef = useRef(contextKey);
|
||||||
|
contextRef.current = contextKey;
|
||||||
|
const draftRef = useRef<Record<string, unknown> | null>(null);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
const editSequenceRef = useRef(0);
|
||||||
|
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||||
|
const discardGenerationRef = useRef(0);
|
||||||
const resolveConcurrencyConflict = useConcurrencyConflictResolver();
|
const resolveConcurrencyConflict = useConcurrencyConflictResolver();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,13 +114,37 @@ export function useCampaignDraftEditor({
|
|||||||
onLoadedRef.current = onLoaded;
|
onLoadedRef.current = onLoaded;
|
||||||
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
|
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
|
||||||
|
|
||||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
const [draft, setDraftState] = useState<Record<string, unknown> | null>(null);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirtyState] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
|
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
|
||||||
const [localError, setLocalError] = useState("");
|
const [localError, setLocalError] = useState("");
|
||||||
|
|
||||||
|
const setDraft = useCallback((update: SetStateAction<Record<string, unknown> | null>) => {
|
||||||
|
const next = typeof update === "function" ? update(draftRef.current) : update;
|
||||||
|
if (next === draftRef.current) return;
|
||||||
|
draftRef.current = next;
|
||||||
|
editSequenceRef.current += 1;
|
||||||
|
setDraftState(next);
|
||||||
|
}, []);
|
||||||
|
const setDirty = useCallback((update: SetStateAction<boolean>) => {
|
||||||
|
dirtyRef.current = typeof update === "function" ? update(dirtyRef.current) : update;
|
||||||
|
setDirtyState(dirtyRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!version) return;
|
if (!version) {
|
||||||
|
if (loadedContextRef.current !== contextKey) {
|
||||||
|
draftRef.current = null;
|
||||||
|
setDraftState(null);
|
||||||
|
setDirty(false);
|
||||||
|
loadedVersionIdRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loadedContextRef.current === contextKey && loadedVersionIdRef.current === version.id && (
|
||||||
|
dirtyRef.current || saveInFlightRef.current || version.edit_revision < (baseRevisionRef.current ?? 0)
|
||||||
|
)) return;
|
||||||
const initialDraft = ensureCampaignDraft(version);
|
const initialDraft = ensureCampaignDraft(version);
|
||||||
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
|
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
|
||||||
baseDraftRef.current = (
|
baseDraftRef.current = (
|
||||||
@@ -116,32 +152,51 @@ export function useCampaignDraftEditor({
|
|||||||
);
|
);
|
||||||
baseRevisionRef.current = version.edit_revision;
|
baseRevisionRef.current = version.edit_revision;
|
||||||
baseEtagRef.current = version.strong_etag;
|
baseEtagRef.current = version.strong_etag;
|
||||||
setDraft(loadedDraft);
|
loadedVersionIdRef.current = version.id;
|
||||||
|
loadedContextRef.current = contextKey;
|
||||||
|
draftRef.current = loadedDraft;
|
||||||
|
setDraftState(loadedDraft);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
setSaveState(loadedLabelRef.current(version));
|
setSaveState(loadedLabelRef.current(version));
|
||||||
onLoadedRef.current?.(version, loadedDraft);
|
onLoadedRef.current?.(version, loadedDraft);
|
||||||
}, [version]);
|
}, [contextKey, version, setDirty]);
|
||||||
|
|
||||||
const markDirty = useCallback(() => {
|
const markDirty = useCallback(() => {
|
||||||
|
editSequenceRef.current += 1;
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
}, []);
|
}, [setDirty]);
|
||||||
|
|
||||||
const patch = useCallback((path: string[], value: unknown) => {
|
const patch = useCallback((path: string[], value: unknown) => {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
setDraft((current) => updateNested(current ?? {}, path, value));
|
setDraft((current) => updateNested(current ?? {}, path, value));
|
||||||
markDirty();
|
markDirty();
|
||||||
}, [locked, markDirty]);
|
}, [locked, markDirty, setDraft]);
|
||||||
|
|
||||||
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
|
const saveDraft = useCallback((_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
|
||||||
if (!draft || !version || locked) return false;
|
if (saveInFlightRef.current) return saveInFlightRef.current;
|
||||||
|
const submittedDraft = draftRef.current;
|
||||||
|
if (!submittedDraft || !version || locked) return Promise.resolve(false);
|
||||||
|
discardGenerationRef.current += 1;
|
||||||
|
const submittedSequence = editSequenceRef.current;
|
||||||
|
const submittedVersionId = version.id;
|
||||||
|
const stillCurrent = () => loadedVersionIdRef.current === submittedVersionId && contextRef.current === contextKey;
|
||||||
|
setSaving(true);
|
||||||
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
|
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
|
||||||
setError("");
|
setError("");
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
|
const operation = (async () => {
|
||||||
try {
|
try {
|
||||||
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
|
const draftToSave = transformDraftBeforeSaveRef.current?.(submittedDraft) ?? submittedDraft;
|
||||||
const additionalPayload = extraPayload?.() ?? {};
|
const additionalPayload = extraPayload?.() ?? {};
|
||||||
|
if (version.mail_profile_migration_required && !additionalPayload.migrate_legacy_mail_settings
|
||||||
|
&& !campaignMailReferencesUnchanged(baseDraftRef.current ?? getCampaignJson(version), draftToSave)) {
|
||||||
|
// Unchanged Mail references allow independent content/archive repairs;
|
||||||
|
// the server retains legacy transport without migrating it. Changing
|
||||||
|
// the reference still needs the explicit audited Mail settings action.
|
||||||
|
throw new Error("i18n:govoplan-campaign.legacy_mail_migration_required");
|
||||||
|
}
|
||||||
let mutation = campaignVersionMutation(
|
let mutation = campaignVersionMutation(
|
||||||
version,
|
version,
|
||||||
draftToSave,
|
draftToSave,
|
||||||
@@ -181,6 +236,7 @@ export function useCampaignDraftEditor({
|
|||||||
campaignId,
|
campaignId,
|
||||||
version.id
|
version.id
|
||||||
);
|
);
|
||||||
|
if (!stillCurrent()) return false;
|
||||||
const latestLoaded = transformLoadedDraftRef.current?.(
|
const latestLoaded = transformLoadedDraftRef.current?.(
|
||||||
latest,
|
latest,
|
||||||
ensureCampaignDraft(latest)
|
ensureCampaignDraft(latest)
|
||||||
@@ -211,10 +267,20 @@ export function useCampaignDraftEditor({
|
|||||||
resourceLabel: `Campaign version ${latest.version_number}`,
|
resourceLabel: `Campaign version ${latest.version_number}`,
|
||||||
merge
|
merge
|
||||||
});
|
});
|
||||||
if (resolution.action === "cancel") return false;
|
if (!stillCurrent()) return false;
|
||||||
|
if (resolution.action === "cancel") {
|
||||||
|
setSaveState("i18n:govoplan-campaign.save_cancelled");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (resolution.action === "reload") {
|
if (resolution.action === "reload") {
|
||||||
|
draftRef.current = latestLoaded;
|
||||||
|
setDraftState(latestLoaded);
|
||||||
|
baseDraftRef.current = latestDraft;
|
||||||
|
baseRevisionRef.current = latest.edit_revision;
|
||||||
|
baseEtagRef.current = latest.strong_etag;
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
await reload({ force: true });
|
setSaveState(loadedLabelRef.current(latest));
|
||||||
|
onLoadedRef.current?.(latest, latestLoaded);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
mutation = resolution.value;
|
mutation = resolution.value;
|
||||||
@@ -231,35 +297,83 @@ export function useCampaignDraftEditor({
|
|||||||
"The campaign changed repeatedly while it was being saved. Reload and try again."
|
"The campaign changed repeatedly while it was being saved. Reload and try again."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
setDraft(getCampaignJson(saved));
|
if (!stillCurrent()) return false;
|
||||||
baseDraftRef.current = getCampaignJson(saved);
|
const savedInitial = ensureCampaignDraft(saved);
|
||||||
baseRevisionRef.current = saved.edit_revision;
|
const savedDraft = transformLoadedDraftRef.current?.(saved, savedInitial) ?? savedInitial;
|
||||||
baseEtagRef.current = saved.strong_etag;
|
const newerEdits = editSequenceRef.current !== submittedSequence;
|
||||||
setDirty(false);
|
if (!newerEdits) {
|
||||||
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
|
draftRef.current = savedDraft;
|
||||||
onSaved?.(saved);
|
setDraftState(savedDraft);
|
||||||
await reload();
|
baseDraftRef.current = transformDraftBeforeSaveRef.current?.(savedDraft) ?? savedDraft;
|
||||||
return true;
|
baseRevisionRef.current = saved.edit_revision;
|
||||||
|
baseEtagRef.current = saved.strong_etag;
|
||||||
|
setDirty(false);
|
||||||
|
}
|
||||||
|
// A late local edit keeps its original merge base and revision. The next
|
||||||
|
// explicit save reconciles against the acknowledged server revision,
|
||||||
|
// rather than silently overwriting concurrent changes merged into it.
|
||||||
|
if (newerEdits) setDirty(true);
|
||||||
|
setSaveState(newerEdits ? "i18n:govoplan-campaign.saved_newer_changes_pending"
|
||||||
|
: `Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
|
||||||
|
try {
|
||||||
|
if (!newerEdits) {
|
||||||
|
onSaved?.(saved);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The POST acknowledgement is authoritative. A failed refresh or
|
||||||
|
// observer must not report a committed mutation as a failed save.
|
||||||
|
setError("i18n:govoplan-campaign.saved_refresh_failed");
|
||||||
|
}
|
||||||
|
return !newerEdits && !dirtyRef.current;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const text = err instanceof Error ? err.message : String(err);
|
const text = err instanceof Error ? err.message : String(err);
|
||||||
setLocalError(text);
|
if (stillCurrent()) {
|
||||||
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
|
setLocalError(text);
|
||||||
|
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setError, settings, version, workflowState]);
|
})();
|
||||||
|
const pending = operation.finally(() => {
|
||||||
|
saveInFlightRef.current = null;
|
||||||
|
setSaving(false);
|
||||||
|
});
|
||||||
|
saveInFlightRef.current = pending;
|
||||||
|
return pending;
|
||||||
|
}, [campaignId, contextKey, currentFlow, currentStep, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setDirty, setError, settings, version, workflowState]);
|
||||||
|
|
||||||
const discardDraft = useCallback(async () => {
|
const discardDraft = useCallback(async () => {
|
||||||
if (version) {
|
if (saveInFlightRef.current || !version) return;
|
||||||
const initialDraft = ensureCampaignDraft(version);
|
const generation = ++discardGenerationRef.current;
|
||||||
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
|
const sequence = editSequenceRef.current;
|
||||||
setDraft(loadedDraft);
|
const stillCurrent = () => generation === discardGenerationRef.current
|
||||||
|
&& loadedVersionIdRef.current === version.id && contextRef.current === contextKey;
|
||||||
|
try {
|
||||||
|
// Do not discard local work until a fresh version was actually read.
|
||||||
|
const latest = await getCampaignVersion(settings, campaignId, version.id);
|
||||||
|
if (!stillCurrent()) return;
|
||||||
|
if (sequence !== editSequenceRef.current || saveInFlightRef.current
|
||||||
|
|| latest.edit_revision < (baseRevisionRef.current ?? 0)) {
|
||||||
|
setLocalError("i18n:govoplan-campaign.discard_superseded");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const initialDraft = ensureCampaignDraft(latest);
|
||||||
|
const loadedDraft = transformLoadedDraftRef.current?.(latest, initialDraft) ?? initialDraft;
|
||||||
|
draftRef.current = loadedDraft;
|
||||||
|
setDraftState(loadedDraft);
|
||||||
|
baseDraftRef.current = transformDraftBeforeSaveRef.current?.(loadedDraft) ?? loadedDraft;
|
||||||
|
baseRevisionRef.current = latest.edit_revision;
|
||||||
|
baseEtagRef.current = latest.strong_etag;
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
setLocalError("");
|
setLocalError("");
|
||||||
setSaveState(loadedLabelRef.current(version));
|
setSaveState(loadedLabelRef.current(latest));
|
||||||
onLoadedRef.current?.(version, loadedDraft);
|
onLoadedRef.current?.(latest, loadedDraft);
|
||||||
|
await reload({ force: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (stillCurrent()) setLocalError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
await reload({ force: true });
|
}, [campaignId, contextKey, reload, setDirty, settings, version]);
|
||||||
}, [reload, version]);
|
|
||||||
|
|
||||||
const unsavedRegistration = useMemo(() => dirty && !locked ? {
|
const unsavedRegistration = useMemo(() => dirty && !locked ? {
|
||||||
title: unsavedTitle,
|
title: unsavedTitle,
|
||||||
@@ -275,6 +389,7 @@ export function useCampaignDraftEditor({
|
|||||||
setDraft,
|
setDraft,
|
||||||
displayDraft: draft ?? ensureCampaignDraft(null),
|
displayDraft: draft ?? ensureCampaignDraft(null),
|
||||||
dirty,
|
dirty,
|
||||||
|
saving,
|
||||||
setDirty,
|
setDirty,
|
||||||
saveState,
|
saveState,
|
||||||
setSaveState,
|
setSaveState,
|
||||||
@@ -298,7 +413,7 @@ function campaignVersionMutation(
|
|||||||
current_step: overrides.current_step ?? version.current_step ?? null,
|
current_step: overrides.current_step ?? version.current_step ?? null,
|
||||||
workflow_state: overrides.workflow_state ?? version.workflow_state ?? null,
|
workflow_state: overrides.workflow_state ?? version.workflow_state ?? null,
|
||||||
is_complete: overrides.is_complete ?? version.is_complete ?? false,
|
is_complete: overrides.is_complete ?? version.is_complete ?? false,
|
||||||
editor_state: overrides.editor_state ?? version.editor_state ?? {},
|
editor_state: clientCampaignEditorState(overrides.editor_state ?? version.editor_state),
|
||||||
source_filename: overrides.source_filename ?? version.source_filename ?? null,
|
source_filename: overrides.source_filename ?? version.source_filename ?? null,
|
||||||
source_base_path: overrides.source_base_path ?? version.source_base_path ?? null,
|
source_base_path: overrides.source_base_path ?? version.source_base_path ?? null,
|
||||||
migrate_legacy_mail_settings: (
|
migrate_legacy_mail_settings: (
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { ApiError } from "@govoplan/core-webui";
|
||||||
|
import { getCampaignVersion, updateCampaignReviewState, type CampaignReviewStatePayload, type CampaignVersionDetail } from "../../../api/campaigns";
|
||||||
|
import type { ApiSettings } from "../../../types";
|
||||||
|
import { storedMessageReviewState } from "../review/builtMessageQuery";
|
||||||
|
|
||||||
|
type ReviewProgressDelta = Pick<CampaignReviewStatePayload, "reviewed_message_keys" | "issue_decisions" | "decision_category_key"> & {
|
||||||
|
inspection_complete?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A decision ACK is durable progress, not a full workspace reload or rebuild. */
|
||||||
|
export function useCampaignReviewProgress(settings: ApiSettings, campaignId: string, version: CampaignVersionDetail | null) {
|
||||||
|
const buildToken = storedMessageReviewState(version).buildToken;
|
||||||
|
const key = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, version?.id, buildToken]);
|
||||||
|
const currentKey = useRef(key);
|
||||||
|
currentKey.current = key;
|
||||||
|
const latest = useRef({ key, version });
|
||||||
|
if (latest.current.key !== key || (version && version.edit_revision > (latest.current.version?.edit_revision ?? -1))) {
|
||||||
|
latest.current = { key, version };
|
||||||
|
}
|
||||||
|
const [, renderAcknowledgement] = useState(0);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const pending = useRef(false);
|
||||||
|
const effectiveVersion = storedMessageReviewState(latest.current.version).buildToken === buildToken ? latest.current.version : version;
|
||||||
|
|
||||||
|
async function recordProgress(delta: ReviewProgressDelta): Promise<CampaignVersionDetail> {
|
||||||
|
const base = latest.current.version;
|
||||||
|
if (pending.current) throw new Error("i18n:govoplan-campaign.review_save_pending");
|
||||||
|
if (!base || !buildToken || storedMessageReviewState(base).buildToken !== buildToken) {
|
||||||
|
throw new Error("i18n:govoplan-campaign.review_build_changed");
|
||||||
|
}
|
||||||
|
pending.current = true;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await updateCampaignReviewState(settings, campaignId, base.id, {
|
||||||
|
...delta,
|
||||||
|
inspection_complete: delta.inspection_complete === true,
|
||||||
|
merge_progress: true,
|
||||||
|
build_token: buildToken,
|
||||||
|
base_revision: base.edit_revision
|
||||||
|
});
|
||||||
|
if (currentKey.current !== key) throw new Error("i18n:govoplan-campaign.review_build_changed");
|
||||||
|
const current = latest.current.version;
|
||||||
|
if (current && storedMessageReviewState(current).buildToken !== buildToken) {
|
||||||
|
throw new Error("i18n:govoplan-campaign.review_build_changed");
|
||||||
|
}
|
||||||
|
// A workspace refresh can observe a newer same-build revision while
|
||||||
|
// this request is still in flight. Never replace it with an older ACK.
|
||||||
|
const acknowledged = current && current.edit_revision > saved.edit_revision ? current : saved;
|
||||||
|
latest.current = { key, version: acknowledged };
|
||||||
|
renderAcknowledgement(value => value + 1);
|
||||||
|
return acknowledged;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 409) {
|
||||||
|
// Refresh only authoritative revision/evidence. Never automatically
|
||||||
|
// replay an approval, or reconcile it onto another frozen build.
|
||||||
|
try {
|
||||||
|
const fresh = await getCampaignVersion(settings, campaignId, base.id);
|
||||||
|
if (currentKey.current === key && fresh.edit_revision >= (latest.current.version?.edit_revision ?? -1)) {
|
||||||
|
latest.current = { key, version: fresh };
|
||||||
|
renderAcknowledgement(value => value + 1);
|
||||||
|
}
|
||||||
|
} catch { /* Keep the submitted note and original conflict visible. */ }
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
pending.current = false;
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reviewVersion: effectiveVersion, recordProgress, saving, buildToken };
|
||||||
|
}
|
||||||
@@ -33,10 +33,12 @@ export function useCampaignWorkspaceData(
|
|||||||
} = options;
|
} = options;
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const selectedVersionId = searchParams.get("version");
|
const selectedVersionId = searchParams.get("version");
|
||||||
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
|
const [data, setData] = useState<{ queryKey: string; value: CampaignWorkspaceData } | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const dataRef = useRef<CampaignWorkspaceData>(initialData);
|
const dataRef = useRef<{ queryKey: string; value: CampaignWorkspaceData } | null>(null);
|
||||||
|
const requestGeneration = useRef(0);
|
||||||
|
const requestAbort = useRef<AbortController | null>(null);
|
||||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||||
const queryKey = useMemo(
|
const queryKey = useMemo(
|
||||||
() => JSON.stringify({
|
() => JSON.stringify({
|
||||||
@@ -51,9 +53,18 @@ 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 activeQueryKey = useRef(queryKey);
|
||||||
|
activeQueryKey.current = queryKey;
|
||||||
|
|
||||||
const reload = useCallback(async (options?: {force?: boolean;}) => {
|
const reload = useCallback(async (options?: {force?: boolean;}) => {
|
||||||
if (!campaignId) return;
|
if (!campaignId || activeQueryKey.current !== queryKey) return;
|
||||||
|
const generation = ++requestGeneration.current;
|
||||||
|
requestAbort.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
requestAbort.current = controller;
|
||||||
|
const isCurrentRequest = () => (
|
||||||
|
!controller.signal.aborted && activeQueryKey.current === queryKey && requestGeneration.current === generation
|
||||||
|
);
|
||||||
const force = options?.force === true;
|
const force = options?.force === true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -61,7 +72,8 @@ export function useCampaignWorkspaceData(
|
|||||||
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||||
if (force) resetDeltaWatermark(queryKey);
|
if (force) resetDeltaWatermark(queryKey);
|
||||||
let nextWatermark = force ? null : getDeltaWatermark(queryKey);
|
let nextWatermark = force ? null : getDeltaWatermark(queryKey);
|
||||||
let merged: CampaignWorkspaceData = force ? initialData : dataRef.current;
|
let merged = !force && dataRef.current?.queryKey === queryKey
|
||||||
|
? dataRef.current.value : initialData;
|
||||||
let hasMore = false;
|
let hasMore = false;
|
||||||
do {
|
do {
|
||||||
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
|
const response = await getCampaignWorkspaceDelta(settings, campaignId, {
|
||||||
@@ -70,35 +82,48 @@ export function useCampaignWorkspaceData(
|
|||||||
includeSummary,
|
includeSummary,
|
||||||
includeVersions: shouldLoadVersions,
|
includeVersions: shouldLoadVersions,
|
||||||
since: nextWatermark,
|
since: nextWatermark,
|
||||||
});
|
}, { cache: "no-store", signal: controller.signal });
|
||||||
|
// Do not apply an old campaign/version/auth response or request another
|
||||||
|
// page after a newer reload or navigation has superseded this request.
|
||||||
|
if (!isCurrentRequest()) return;
|
||||||
merged = mergeWorkspaceDelta(merged, response);
|
merged = mergeWorkspaceDelta(merged, response);
|
||||||
nextWatermark = response.watermark ?? null;
|
nextWatermark = response.watermark ?? null;
|
||||||
hasMore = response.has_more;
|
hasMore = response.has_more;
|
||||||
} while (hasMore);
|
} while (hasMore);
|
||||||
setDeltaWatermark(queryKey, nextWatermark);
|
setDeltaWatermark(queryKey, nextWatermark);
|
||||||
dataRef.current = merged;
|
dataRef.current = { queryKey, value: merged };
|
||||||
setData(merged);
|
setData(dataRef.current);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dataRef.current = initialData;
|
if (!isCurrentRequest()) return;
|
||||||
setData(initialData);
|
// A failed refresh does not undo an authoritative save or erase the last
|
||||||
|
// usable same-query workspace. Retry from a full response next time.
|
||||||
resetDeltaWatermark(queryKey);
|
resetDeltaWatermark(queryKey);
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isCurrentRequest()) {
|
||||||
|
requestAbort.current = null;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId, queryKey, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
|
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId, queryKey, getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetDeltaWatermark(queryKey);
|
resetDeltaWatermark(queryKey);
|
||||||
dataRef.current = initialData;
|
dataRef.current = null;
|
||||||
setData(initialData);
|
setData(null);
|
||||||
|
setError("");
|
||||||
}, [queryKey, resetDeltaWatermark]);
|
}, [queryKey, resetDeltaWatermark]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
void reload();
|
||||||
|
return () => {
|
||||||
|
requestGeneration.current += 1;
|
||||||
|
requestAbort.current?.abort();
|
||||||
|
requestAbort.current = null;
|
||||||
|
};
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
return { data, loading, error, reload, setError };
|
return { data: data?.queryKey === queryKey ? data.value : initialData, loading, error, reload, setError };
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeWorkspaceDelta(current: CampaignWorkspaceData, response: CampaignWorkspaceDeltaResponse): CampaignWorkspaceData {
|
function mergeWorkspaceDelta(current: CampaignWorkspaceData, response: CampaignWorkspaceDeltaResponse): CampaignWorkspaceData {
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ function mergeAddressGroups(
|
|||||||
nextValues[group.key] = dedupeAddresses([
|
nextValues[group.key] = dedupeAddresses([
|
||||||
...(nextValues[group.key] ?? []),
|
...(nextValues[group.key] ?? []),
|
||||||
...group.addresses.map(cloneMailboxAddress)
|
...group.addresses.map(cloneMailboxAddress)
|
||||||
]);
|
], { preserveOrder: true });
|
||||||
}
|
}
|
||||||
return nextValues;
|
return nextValues;
|
||||||
}
|
}
|
||||||
@@ -489,7 +489,7 @@ function prepareAddressValues(
|
|||||||
}
|
}
|
||||||
addresses.push({ name, email });
|
addresses.push({ name, email });
|
||||||
}
|
}
|
||||||
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses) : addresses.slice(0, 1);
|
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses, { preserveOrder: true }) : addresses.slice(0, 1);
|
||||||
}
|
}
|
||||||
return { values: prepared, error: "" };
|
return { values: prepared, error: "" };
|
||||||
}
|
}
|
||||||
@@ -521,6 +521,20 @@ export function entryWithAddressList(entry: Record<string, unknown>, key: Addres
|
|||||||
return nextEntry;
|
return nextEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function entryWithAddressValues(
|
||||||
|
entry: Record<string, unknown>, values: HeaderAddressValues, merges: EntryAddressMergeValues
|
||||||
|
): Record<string, unknown> {
|
||||||
|
let next = entry;
|
||||||
|
for (const column of recipientAddressOverlayColumns) {
|
||||||
|
if (!(column.key in values)) continue;
|
||||||
|
next = entryWithAddressList(next, column.key, values[column.key] ?? []);
|
||||||
|
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
|
||||||
|
next = { ...next, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
|
||||||
|
delete next[column.mergeKey.replace("merge_", "combine_")];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function headerAddressValues(columns: EntryAddressColumn[], recipientsSection: Record<string, unknown>): HeaderAddressValues {
|
export function headerAddressValues(columns: EntryAddressColumn[], recipientsSection: Record<string, unknown>): HeaderAddressValues {
|
||||||
return Object.fromEntries(columns.map((column) => [
|
return Object.fromEntries(columns.map((column) => [
|
||||||
column.key,
|
column.key,
|
||||||
@@ -580,7 +594,7 @@ function parsePastedAddressGroups(targetKey: AddressFieldKey, text: string): Arr
|
|||||||
const addressText = prefixed?.text ?? token;
|
const addressText = prefixed?.text ?? token;
|
||||||
const address = parseMailboxAddressText(addressText);
|
const address = parseMailboxAddressText(addressText);
|
||||||
if (!address?.email) continue;
|
if (!address?.email) continue;
|
||||||
grouped.set(key, dedupeAddresses([...(grouped.get(key) ?? []), address]));
|
grouped.set(key, dedupeAddresses([...(grouped.get(key) ?? []), address], { preserveOrder: true }));
|
||||||
}
|
}
|
||||||
return [...grouped.entries()].map(([key, addresses]) => ({ key, addresses }));
|
return [...grouped.entries()].map(([key, addresses]) => ({ key, addresses }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { addressesFromValue, i18nMessage } from "@govoplan/core-webui";
|
||||||
|
import { asRecord } from "../utils/campaignView";
|
||||||
|
|
||||||
|
const groups = [
|
||||||
|
{ id: "to", label: "i18n:govoplan-campaign.report_recipients_to" },
|
||||||
|
{ id: "cc", label: "i18n:govoplan-campaign.report_recipients_cc" },
|
||||||
|
{ id: "bcc", label: "i18n:govoplan-campaign.report_recipients_bcc" }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function reportRecipientGroups(row: Record<string, unknown>) {
|
||||||
|
const resolved = asRecord(row.resolved_recipients);
|
||||||
|
const result = groups.map(group => ({ ...group, addresses: addressesFromValue(resolved[group.id]) }));
|
||||||
|
if (result.every(group => group.addresses.length === 0) && typeof row.recipient_email === "string" && row.recipient_email.trim()) {
|
||||||
|
result[0].addresses = [{ email: row.recipient_email.trim() }];
|
||||||
|
}
|
||||||
|
return result.filter(group => group.addresses.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportRecipientSearchText(row: Record<string, unknown>): string {
|
||||||
|
return reportRecipientGroups(row).flatMap(group => group.addresses.map(address => `${address.name ?? ""} ${address.email}`)).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportRecipients({ row }: { row: Record<string, unknown> }) {
|
||||||
|
const values = reportRecipientGroups(row);
|
||||||
|
if (!values.length) return <span>—</span>;
|
||||||
|
return <div className="recipient-outcome-cell campaign-report-recipient-cell">
|
||||||
|
{values.map(group => <div key={group.id}>
|
||||||
|
<strong>{group.label}: </strong>
|
||||||
|
{group.addresses.map((address, index) => <span key={`${address.email}-${index}`}>
|
||||||
|
{index > 0 ? "; " : ""}{address.name ? `${address.name} <${address.email}>` : address.email}
|
||||||
|
</span>)}
|
||||||
|
</div>)}
|
||||||
|
<span>{String(row.entry_id ?? i18nMessage("i18n:govoplan-campaign.entry_value.b7706ee4", { value0: Number(row.entry_index ?? 0) || 1 }))}</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Button, Dialog, DismissibleAlert, FormField } from "@govoplan/core-webui";
|
||||||
|
import type { ReportReconciliation } from "./reportRecovery";
|
||||||
|
|
||||||
|
export default function ReportRecoveryDialog({ request, onConfirm, onClose }: {
|
||||||
|
request: ReportReconciliation;
|
||||||
|
onConfirm: (note: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const pending = useRef(false);
|
||||||
|
const claim = request.kind === "claim";
|
||||||
|
const accepted = request.kind === "outcome" && ["smtp_accepted", "imap_appended"].includes(request.decision);
|
||||||
|
const confirmLabel = claim ? "i18n:govoplan-campaign.report_recover_claim_confirm"
|
||||||
|
: request.decision === "imap_appended" ? "i18n:govoplan-campaign.report_record_imap_appended"
|
||||||
|
: request.decision === "imap_not_appended" ? "i18n:govoplan-campaign.report_record_imap_not_appended"
|
||||||
|
: accepted ? "i18n:govoplan-campaign.report_record_accepted" : "i18n:govoplan-campaign.report_record_not_sent";
|
||||||
|
async function confirm() {
|
||||||
|
if (pending.current || !note.trim()) return;
|
||||||
|
pending.current = true; setSaving(true); setError("");
|
||||||
|
try { await onConfirm(note.trim()); }
|
||||||
|
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||||
|
finally { pending.current = false; setSaving(false); }
|
||||||
|
}
|
||||||
|
function close() { if (!pending.current) onClose(); }
|
||||||
|
return <Dialog open portal size="small" title={claim ? "i18n:govoplan-campaign.report_recover_claim_title" : "i18n:govoplan-campaign.report_reconcile_title"}
|
||||||
|
closeDisabled={saving} onClose={close}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={close} disabled={saving}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||||
|
<Button variant={accepted ? "primary" : "danger"} onClick={() => void confirm()} disabled={saving || !note.trim()}>
|
||||||
|
{saving ? "i18n:govoplan-campaign.report_recording_evidence" : confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<p>{claim ? "i18n:govoplan-campaign.report_recover_claim_help" : accepted ? "i18n:govoplan-campaign.report_accepted_evidence_help" : "i18n:govoplan-campaign.report_not_sent_evidence_help"}</p>
|
||||||
|
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
|
||||||
|
<FormField label="i18n:govoplan-campaign.report_evidence_note">
|
||||||
|
<textarea value={note} onChange={event => setNote(event.target.value)} maxLength={2000} rows={4} required disabled={saving} />
|
||||||
|
</FormField>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { CampaignRecoveryChannel } from "../../../api/deliveryRecovery";
|
||||||
|
import { asRecord } from "../utils/campaignView";
|
||||||
|
|
||||||
|
export type ReportReconciliation = {
|
||||||
|
jobId: string;
|
||||||
|
kind: "outcome";
|
||||||
|
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended";
|
||||||
|
} | {
|
||||||
|
jobId: string;
|
||||||
|
kind: "claim";
|
||||||
|
channel: CampaignRecoveryChannel;
|
||||||
|
revision: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function reportClaimRecovery(row: Record<string, unknown>, channel: CampaignRecoveryChannel): { eligible: boolean; revision: string } {
|
||||||
|
const metadata = asRecord(asRecord(row.recovery)[channel]);
|
||||||
|
const revision = typeof metadata.revision === "string" ? metadata.revision : "";
|
||||||
|
const state = String(channel === "smtp" ? row.send_status ?? "" : row.imap_status ?? "");
|
||||||
|
return { eligible: metadata.eligible === true && Boolean(revision) &&
|
||||||
|
(channel === "smtp" ? ["claimed", "sending"].includes(state) : state === "appending"), revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportRetryableFailure(row: Record<string, unknown>): boolean {
|
||||||
|
return ["failed_temporary", "failed_permanent", "partially_accepted"].includes(String(row.send_status ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportUnattempted(row: Record<string, unknown>): boolean {
|
||||||
|
return ["not_queued", "cancelled", "queued"].includes(String(row.send_status ?? "")) &&
|
||||||
|
Number(row.attempt_count ?? 0) === 0 && Number(row.postbox_attempt_count ?? 0) === 0 &&
|
||||||
|
Number(row.print_attempt_count ?? 0) === 0 &&
|
||||||
|
row.build_status === "built" && ["ready", "warning", "needs_review"].includes(String(row.validation_status ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportRecoveryHints(row: Record<string, unknown>): string[] {
|
||||||
|
const activeChannels = [
|
||||||
|
...(["claimed", "sending"].includes(String(row.send_status ?? "")) ? ["smtp"] : []),
|
||||||
|
...(row.imap_status === "appending" ? ["imap"] : [])
|
||||||
|
];
|
||||||
|
return [...new Set(activeChannels.flatMap(channel => {
|
||||||
|
const metadata = asRecord(asRecord(row.recovery)[channel]);
|
||||||
|
if (metadata.eligible === true) return [];
|
||||||
|
return [metadata.reason === "live_claim" ? "i18n:govoplan-campaign.report_claim_still_active"
|
||||||
|
: "i18n:govoplan-campaign.report_claim_owner_unconfirmed"];
|
||||||
|
}))];
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export default function AttachmentLinkingPreview({
|
|||||||
error,
|
error,
|
||||||
linking,
|
linking,
|
||||||
disabled,
|
disabled,
|
||||||
|
disabledReason,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onLink
|
onLink
|
||||||
}: {
|
}: {
|
||||||
@@ -25,6 +26,7 @@ export default function AttachmentLinkingPreview({
|
|||||||
error: string;
|
error: string;
|
||||||
linking: boolean;
|
linking: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onLink: () => void;
|
onLink: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -57,6 +59,7 @@ export default function AttachmentLinkingPreview({
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={onLink}
|
onClick={onLink}
|
||||||
disabled={disabled || loading || linking || unlinkedCount === 0}
|
disabled={disabled || loading || linking || unlinkedCount === 0}
|
||||||
|
title={loading ? "i18n:govoplan-campaign.attachment_link_loading_reason" : disabledReason || (unlinkedCount === 0 ? "i18n:govoplan-campaign.attachment_link_all_done" : undefined)}
|
||||||
>
|
>
|
||||||
{linking
|
{linking
|
||||||
? "i18n:govoplan-campaign.linking.a5f54e0f"
|
? "i18n:govoplan-campaign.linking.a5f54e0f"
|
||||||
@@ -69,6 +72,7 @@ export default function AttachmentLinkingPreview({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="muted small-note">{disabledReason || "i18n:govoplan-campaign.attachment_link_before_lock"}</p>
|
||||||
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||||
<MetricCard density="compact" surface="subtle"
|
<MetricCard density="compact" surface="subtle"
|
||||||
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
DismissibleAlert,
|
||||||
FormField,
|
FormField,
|
||||||
i18nMessage
|
i18nMessage
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import CampaignMessagePreviewOverlay, {
|
import CampaignMessagePreviewOverlay, {
|
||||||
type CampaignMessagePreviewAttachment
|
type CampaignMessagePreviewAttachment
|
||||||
@@ -34,6 +36,9 @@ export default function BuiltMessagePreview({
|
|||||||
singleMessageSendBusy,
|
singleMessageSendBusy,
|
||||||
reviewed,
|
reviewed,
|
||||||
reviewReason,
|
reviewReason,
|
||||||
|
reviewSaving = false,
|
||||||
|
reviewDisabled = false,
|
||||||
|
reviewError = "",
|
||||||
onReviewReasonChange,
|
onReviewReasonChange,
|
||||||
onAcceptReview,
|
onAcceptReview,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -48,8 +53,11 @@ export default function BuiltMessagePreview({
|
|||||||
singleMessageSendBusy: boolean;
|
singleMessageSendBusy: boolean;
|
||||||
reviewed: boolean;
|
reviewed: boolean;
|
||||||
reviewReason: string;
|
reviewReason: string;
|
||||||
|
reviewSaving?: boolean;
|
||||||
|
reviewDisabled?: boolean;
|
||||||
|
reviewError?: string;
|
||||||
onReviewReasonChange: (value: string) => void;
|
onReviewReasonChange: (value: string) => void;
|
||||||
onAcceptReview: (reasonRequired: boolean) => void;
|
onAcceptReview: (reasonRequired: boolean, reason: string) => Promise<void>;
|
||||||
onSelect: (index: number) => void;
|
onSelect: (index: number) => void;
|
||||||
onSendSingle: (index: number) => void;
|
onSendSingle: (index: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -128,40 +136,16 @@ export default function BuiltMessagePreview({
|
|||||||
}}
|
}}
|
||||||
actions={
|
actions={
|
||||||
<div className="built-message-review-actions">
|
<div className="built-message-review-actions">
|
||||||
|
{reviewError && <DismissibleAlert tone="danger" dismissible={false}>{reviewError}</DismissibleAlert>}
|
||||||
{explicitReview && !reviewed ? (
|
{explicitReview && !reviewed ? (
|
||||||
<>
|
<ReviewDecisionForm key={String(row.id ?? row.review_key ?? index)} initialReason={reviewReason}
|
||||||
<FormField
|
reasonRequired={reasonRequired} saving={reviewSaving} disabled={reviewDisabled}
|
||||||
label={
|
onReasonChange={onReviewReasonChange} onAccept={(reason) => onAcceptReview(reasonRequired, reason)} />
|
||||||
reasonRequired
|
|
||||||
? "Reason for accepting attachment exception"
|
|
||||||
: "Review note"
|
|
||||||
}
|
|
||||||
help={
|
|
||||||
reasonRequired
|
|
||||||
? "Required. This reason is stored with the frozen build evidence."
|
|
||||||
: "Optional note stored with the review decision."
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={reviewReason}
|
|
||||||
maxLength={4000}
|
|
||||||
onChange={(event) =>
|
|
||||||
onReviewReasonChange(event.target.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={reasonRequired && !reviewReason.trim()}
|
|
||||||
onClick={() => onAcceptReview(reasonRequired)}
|
|
||||||
>
|
|
||||||
Accept review conditions
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
{reviewed && <p role="status">i18n:govoplan-campaign.review_decision_saved</p>}
|
||||||
<Button
|
<Button
|
||||||
variant={explicitReview && !reviewed ? undefined : "primary"}
|
variant={explicitReview && !reviewed ? undefined : "primary"}
|
||||||
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
disabled={reviewSaving || singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
||||||
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
|
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
|
||||||
onClick={() => onSendSingle(index)}
|
onClick={() => onSendSingle(index)}
|
||||||
>
|
>
|
||||||
@@ -169,11 +153,32 @@ export default function BuiltMessagePreview({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
closeDisabled={reviewSaving}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keystrokes rerender only this small form, not the campaign table, template
|
||||||
|
// rendering and message body. The parent retains a draft ref for navigation.
|
||||||
|
function ReviewDecisionForm({ initialReason, reasonRequired, saving, disabled, onReasonChange, onAccept }: {
|
||||||
|
initialReason: string; reasonRequired: boolean; saving: boolean; disabled: boolean;
|
||||||
|
onReasonChange: (value: string) => void; onAccept: (reason: string) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [reason, setReason] = useState(initialReason);
|
||||||
|
return <>
|
||||||
|
<FormField label={reasonRequired ? "i18n:govoplan-campaign.review_reason_label" : "i18n:govoplan-campaign.review_note_label"}
|
||||||
|
help="i18n:govoplan-campaign.review_reason_help">
|
||||||
|
<input value={reason} maxLength={4000} disabled={saving || disabled} onChange={event => {
|
||||||
|
setReason(event.target.value); onReasonChange(event.target.value);
|
||||||
|
}} />
|
||||||
|
</FormField>
|
||||||
|
<Button variant="primary" disabled={saving || disabled || reasonRequired && !reason.trim()} onClick={() => void onAccept(reason)}>
|
||||||
|
{saving ? "i18n:govoplan-campaign.review_saving_decision" : "i18n:govoplan-campaign.review_accept_next"}
|
||||||
|
</Button>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
function singleMessageSendDisabledReason(
|
function singleMessageSendDisabledReason(
|
||||||
row: Record<string, unknown>,
|
row: Record<string, unknown>,
|
||||||
canStartSingleMessageSend: boolean
|
canStartSingleMessageSend: boolean
|
||||||
@@ -247,7 +252,7 @@ function builtMessageMetaItems(row: Record<string, unknown>) {
|
|||||||
function builtMessageAttachments(
|
function builtMessageAttachments(
|
||||||
row: Record<string, unknown>
|
row: Record<string, unknown>
|
||||||
): CampaignMessagePreviewAttachment[] {
|
): CampaignMessagePreviewAttachment[] {
|
||||||
return asArray(row.attachments).flatMap((value, index) => {
|
return asArray(row.attachments).flatMap<CampaignMessagePreviewAttachment>((value, index) => {
|
||||||
const attachment = asRecord(value);
|
const attachment = asRecord(value);
|
||||||
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
||||||
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { memo, useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button, Dialog, DismissibleAlert, FormField, ToggleSwitch, i18nMessage, usePlatformLanguage
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import {
|
||||||
|
BULK_MESSAGE_REVIEW_LIMIT, bulkMessageReviewGroups, bulkReviewIssueLabel, type BulkMessageReviewRow, type BulkMessageReviewSelection
|
||||||
|
} from "./bulkMessageReview";
|
||||||
|
|
||||||
|
export default function BulkMessageReviewDialog({ rows, buildToken, disabled = false, onAccept, onClose }: {
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
buildToken: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onAccept: (selection: BulkMessageReviewSelection) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { translateText } = usePlatformLanguage();
|
||||||
|
const groups = useMemo(() => bulkMessageReviewGroups(rows), [rows]);
|
||||||
|
const [categoryKey, setCategoryKey] = useState(() => groups[0]?.categoryKey ?? "");
|
||||||
|
const [excluded, setExcluded] = useState<Set<string>>(() => new Set());
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
const openedBuildToken = useRef(buildToken);
|
||||||
|
const group = groups.find((item) => item.categoryKey === categoryKey);
|
||||||
|
const messages = useMemo(() => group?.messages.slice(0, BULK_MESSAGE_REVIEW_LIMIT) ?? [], [group]);
|
||||||
|
const selected = messages.filter((item) => !excluded.has(item.jobId));
|
||||||
|
const toggleMessage = useCallback((jobId: string, checked: boolean) => setExcluded((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (checked) next.delete(jobId); else next.add(jobId);
|
||||||
|
return next;
|
||||||
|
}), []);
|
||||||
|
const staleBuild = openedBuildToken.current !== buildToken;
|
||||||
|
const unavailable = disabled || saving || staleBuild || !buildToken;
|
||||||
|
async function accept() {
|
||||||
|
if (inFlight.current || unavailable || !group || selected.length === 0 || (group.reasonRequired && !reason.trim())) return;
|
||||||
|
inFlight.current = true;
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await onAccept({ buildToken, categoryKey: group.categoryKey, jobIds: selected.map((item) => item.jobId), reason: reason.trim() });
|
||||||
|
onClose();
|
||||||
|
} catch (failure) {
|
||||||
|
setError(failure instanceof Error ? failure.message : String(failure));
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <Dialog open title="i18n:govoplan-campaign.bulk_review_title" size="large" closeDisabled={saving}
|
||||||
|
onClose={() => { if (!inFlight.current) onClose(); }}
|
||||||
|
footer={<>
|
||||||
|
<Button disabled={saving} onClick={() => { if (!inFlight.current) onClose(); }}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||||
|
<Button variant="primary" disabled={unavailable || selected.length === 0 || Boolean(group?.reasonRequired && !reason.trim())} onClick={() => void accept()}>
|
||||||
|
{saving ? "i18n:govoplan-campaign.bulk_review_saving" : i18nMessage("i18n:govoplan-campaign.bulk_review_accept_count", { value0: selected.length })}
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<p>i18n:govoplan-campaign.bulk_review_scope</p>
|
||||||
|
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
|
||||||
|
{staleBuild && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.bulk_review_stale</DismissibleAlert>}
|
||||||
|
{groups.length === 0 ? <p>i18n:govoplan-campaign.bulk_review_empty</p> : <>
|
||||||
|
<FormField label="i18n:govoplan-campaign.bulk_review_category">
|
||||||
|
<select aria-label={translateText("i18n:govoplan-campaign.bulk_review_category")} value={categoryKey} disabled={unavailable}
|
||||||
|
onChange={(event) => { setCategoryKey(event.target.value); setExcluded(new Set()); setReason(""); setError(""); }}>
|
||||||
|
{groups.map((item) => <option key={item.categoryKey} value={item.categoryKey}>
|
||||||
|
{`${[...new Set(item.issueCodes.map((code) => translateText(bulkReviewIssueLabel(code))))].join(" + ") || translateText("i18n:govoplan-campaign.bulk_review_other_category")} (${item.messages.length})`}
|
||||||
|
</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{Boolean(group?.issueCodes.length) && <details>
|
||||||
|
<summary>i18n:govoplan-campaign.bulk_review_technical_codes</summary>
|
||||||
|
<p>{group?.issueCodes.join(", ")}</p>
|
||||||
|
</details>}
|
||||||
|
<p>{i18nMessage("i18n:govoplan-campaign.bulk_review_subset", { value0: messages.length, value1: group?.messages.length ?? 0, value2: selected.length })}</p>
|
||||||
|
{(group?.messages.length ?? 0) > BULK_MESSAGE_REVIEW_LIMIT && <p className="muted small-note">i18n:govoplan-campaign.bulk_review_limit</p>}
|
||||||
|
<FormField label={group?.reasonRequired ? "i18n:govoplan-campaign.bulk_review_reason_required" : "i18n:govoplan-campaign.bulk_review_reason"}
|
||||||
|
help="i18n:govoplan-campaign.bulk_review_reason_help">
|
||||||
|
<textarea aria-label={translateText(group?.reasonRequired ? "i18n:govoplan-campaign.bulk_review_reason_required" : "i18n:govoplan-campaign.bulk_review_reason")}
|
||||||
|
value={reason} maxLength={4000} rows={3} disabled={unavailable} onChange={(event) => setReason(event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
<Button disabled={unavailable} onClick={() => setExcluded(new Set())}>i18n:govoplan-campaign.bulk_review_select_all</Button>
|
||||||
|
<Button disabled={unavailable} onClick={() => setExcluded(new Set(messages.map((item) => item.jobId)))}>i18n:govoplan-campaign.bulk_review_select_none</Button>
|
||||||
|
</div>
|
||||||
|
<div className="review-flow-data-stack" role="group" aria-label={translateText("i18n:govoplan-campaign.bulk_review_recipients")}>
|
||||||
|
<BulkReviewRecipients messages={messages} excluded={excluded} disabled={unavailable} onChange={toggleMessage} />
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing a shared reason must not rebuild a potentially 200-row recipient list.
|
||||||
|
const BulkReviewRecipients = memo(function BulkReviewRecipients({ messages, excluded, disabled, onChange }: {
|
||||||
|
messages: BulkMessageReviewRow[];
|
||||||
|
excluded: Set<string>;
|
||||||
|
disabled: boolean;
|
||||||
|
onChange: (jobId: string, checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return <>{messages.map((item) => <ToggleSwitch key={item.jobId} label={item.recipient} help={item.subject || undefined}
|
||||||
|
disabled={disabled} checked={!excluded.has(item.jobId)} onChange={(checked) => onChange(item.jobId, checked)} />)}</>;
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, DescriptionList, Dialog, DismissibleAlert, MetricCard, MetricGrid, i18nMessage } from "@govoplan/core-webui";
|
||||||
|
import type { ApiSettings } from "../../../types";
|
||||||
|
import { getCampaignDeliveryProgress, type CampaignDeliveryProgress } from "../../../api/campaigns";
|
||||||
|
|
||||||
|
export type DeliveryProgressRequestState = "running" | "finished" | "interrupted";
|
||||||
|
|
||||||
|
/** Only this small dialog polls; the workspace, attachments and report stay still. */
|
||||||
|
export default function CampaignDeliveryProgressDialog({ settings, campaignId, versionId, kind, requestState, requestError, onClose }: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
campaignId: string;
|
||||||
|
versionId: string;
|
||||||
|
kind: "smtp" | "imap";
|
||||||
|
requestState: DeliveryProgressRequestState;
|
||||||
|
requestError?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const scope = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, campaignId, versionId]);
|
||||||
|
const [snapshot, setSnapshot] = useState<{ scope: string; progress: CampaignDeliveryProgress } | null>(null);
|
||||||
|
const [pollError, setPollError] = useState(false);
|
||||||
|
const progress = snapshot?.scope === scope ? snapshot.progress : null;
|
||||||
|
const running = requestState === "running";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let stopped = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const controller = new AbortController();
|
||||||
|
setPollError(false);
|
||||||
|
async function poll() {
|
||||||
|
let succeeded = false;
|
||||||
|
try {
|
||||||
|
const result = await getCampaignDeliveryProgress(settings, campaignId, versionId, controller.signal);
|
||||||
|
if (stopped) return;
|
||||||
|
if (result.campaign_id !== campaignId || result.version_id !== versionId) throw new Error("Progress context changed");
|
||||||
|
setSnapshot({ scope, progress: result });
|
||||||
|
setPollError(false);
|
||||||
|
succeeded = true;
|
||||||
|
} catch {
|
||||||
|
if (stopped) return;
|
||||||
|
// A read failure never retries or changes the authoritative send/append.
|
||||||
|
setPollError(true);
|
||||||
|
}
|
||||||
|
if (!stopped && (requestState !== "finished" || !succeeded)) timer = setTimeout(() => void poll(), 2000);
|
||||||
|
}
|
||||||
|
void poll();
|
||||||
|
return () => { stopped = true; controller.abort(); if (timer) clearTimeout(timer); };
|
||||||
|
}, [scope, requestState]);
|
||||||
|
|
||||||
|
const counts = progress?.[kind];
|
||||||
|
const accepted = progress ? kind === "smtp" ? progress.smtp.accepted : progress.imap.appended : null;
|
||||||
|
const label = kind === "smtp" ? "i18n:govoplan-campaign.delivery_progress_smtp" : "i18n:govoplan-campaign.delivery_progress_imap";
|
||||||
|
return <Dialog open title={label} closeDisabled={running} closeOnBackdrop={false} onClose={onClose}
|
||||||
|
footer={<Button variant="primary" disabled={running} onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||||
|
<p role="status" aria-live="polite"><strong>{counts
|
||||||
|
? i18nMessage("i18n:govoplan-campaign.delivery_progress_processed", { value0: counts.processed, value1: counts.total })
|
||||||
|
: "i18n:govoplan-campaign.delivery_progress_loading"}</strong></p>
|
||||||
|
<p className="muted small-note">{running ? "i18n:govoplan-campaign.delivery_progress_running"
|
||||||
|
: requestState === "finished" ? "i18n:govoplan-campaign.delivery_progress_finished"
|
||||||
|
: "i18n:govoplan-campaign.delivery_progress_interrupted"}</p>
|
||||||
|
{pollError && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.delivery_progress_stale</DismissibleAlert>}
|
||||||
|
{requestError && <DismissibleAlert tone="warning" dismissible={false}>{requestError}</DismissibleAlert>}
|
||||||
|
<MetricGrid columns={3} density="compact" minimum="compact">
|
||||||
|
<MetricCard label={kind === "smtp" ? "i18n:govoplan-campaign.delivery_progress_accepted" : "i18n:govoplan-campaign.delivery_progress_appended"} value={accepted ?? "—"} density="compact" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.delivery_progress_active" value={counts?.active ?? "—"} density="compact" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.delivery_progress_pending" value={counts?.pending ?? "—"} density="compact" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.delivery_progress_failed" value={counts?.failed ?? "—"} density="compact" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.delivery_progress_unknown" value={counts?.outcome_unknown ?? "—"} density="compact" />
|
||||||
|
<MetricCard label="i18n:govoplan-campaign.delivery_progress_excluded" value={counts?.excluded ?? "—"} density="compact" />
|
||||||
|
</MetricGrid>
|
||||||
|
<DescriptionList variant="inline">
|
||||||
|
{kind === "smtp" && <><div><dt>i18n:govoplan-campaign.delivery_progress_paused</dt><dd>{progress?.smtp.paused ?? "—"}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-campaign.delivery_progress_cancelled</dt><dd>{progress?.smtp.cancelled ?? "—"}</dd></div></>}
|
||||||
|
<div><dt>i18n:govoplan-campaign.delivery_progress_updated</dt><dd>{progress?.generated_at ? new Date(progress.generated_at).toLocaleTimeString() : "—"}</dd></div>
|
||||||
|
</DescriptionList>
|
||||||
|
<p className="muted small-note">i18n:govoplan-campaign.delivery_progress_scope</p>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { DataGridPaginationBar } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
/** Shared table pagination also serves review evidence that reads best as prose. */
|
||||||
|
export default function PaginatedReviewDetails<T>({ items, renderPage }: {
|
||||||
|
items: T[];
|
||||||
|
renderPage: (items: T[]) => ReactNode;
|
||||||
|
}) {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const currentPage = Math.min(page, Math.max(1, Math.ceil(items.length / pageSize)));
|
||||||
|
return <>
|
||||||
|
{renderPage(items.slice((currentPage - 1) * pageSize, currentPage * pageSize))}
|
||||||
|
<DataGridPaginationBar page={currentPage} pageSize={pageSize} totalRows={items.length}
|
||||||
|
pageSizeOptions={[10, 25, 50, 100]} onPageChange={setPage}
|
||||||
|
onPageSizeChange={size => { setPageSize(size); setPage(1); }} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { i18nMessage } from "@govoplan/core-webui";
|
||||||
|
import { humanize } from "../utils/campaignView";
|
||||||
|
import PaginatedReviewDetails from "./PaginatedReviewDetails";
|
||||||
|
|
||||||
|
export default function RepeatedFilesDetails({ findings }: { findings: Record<string, unknown>[] }) {
|
||||||
|
return <PaginatedReviewDetails items={findings} renderPage={page => <ul className="small-note">
|
||||||
|
{page.map((finding, index) => <li key={String(finding.file_fingerprint ?? index)}>
|
||||||
|
{String(finding.file_name ?? "Attachment")} · {i18nMessage("i18n:govoplan-campaign.repeated_file_uses", { value0: Number(finding.use_count ?? 0) })} · {humanize(String(finding.disposition ?? "allowed"))}
|
||||||
|
</li>)}
|
||||||
|
</ul>} />;
|
||||||
|
}
|
||||||
@@ -44,11 +44,13 @@ function InterventionHint({ tone, summary, requiredAction, destination, document
|
|||||||
export function ValidationWorkflowGuidance({
|
export function ValidationWorkflowGuidance({
|
||||||
errors,
|
errors,
|
||||||
warnings,
|
warnings,
|
||||||
stale
|
stale,
|
||||||
|
reviewSatisfied = false
|
||||||
}: {
|
}: {
|
||||||
errors: number;
|
errors: number;
|
||||||
warnings: number;
|
warnings: number;
|
||||||
stale: boolean;
|
stale: boolean;
|
||||||
|
reviewSatisfied?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="review-workflow-guidance">
|
<div className="review-workflow-guidance">
|
||||||
@@ -70,7 +72,7 @@ export function ValidationWorkflowGuidance({
|
|||||||
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
|
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{warnings > 0 && (
|
{warnings > 0 && !reviewSatisfied && (
|
||||||
<InterventionHint
|
<InterventionHint
|
||||||
tone="warning"
|
tone="warning"
|
||||||
summary={i18nMessage("i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1", { value0: warnings })}
|
summary={i18nMessage("i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1", { value0: warnings })}
|
||||||
@@ -124,13 +126,12 @@ export function BuiltMessageReviewProgress({ progress }: { progress: BuildReview
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BuiltMessageWorkflowGuidance({
|
export function BuiltMessageWorkflowGuidance({
|
||||||
progress,
|
progress
|
||||||
buildWarnings
|
|
||||||
}: {
|
}: {
|
||||||
progress: BuildReviewProgress;
|
progress: BuildReviewProgress;
|
||||||
buildWarnings: number;
|
|
||||||
}) {
|
}) {
|
||||||
const showUnacknowledgedWarning = progress.remaining === 0 && buildWarnings > 0;
|
// Frozen job review requirements are authoritative. Aggregate build warnings
|
||||||
|
// also contain expected policy outcomes and already acknowledged conditions.
|
||||||
return (
|
return (
|
||||||
<div className="review-workflow-guidance">
|
<div className="review-workflow-guidance">
|
||||||
{progress.blocking > 0 && (
|
{progress.blocking > 0 && (
|
||||||
@@ -155,15 +156,6 @@ export function BuiltMessageWorkflowGuidance({
|
|||||||
documentationTopicId="campaigns.workflow.complete-review"
|
documentationTopicId="campaigns.workflow.complete-review"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showUnacknowledgedWarning && (
|
|
||||||
<InterventionHint
|
|
||||||
tone="warning"
|
|
||||||
summary={i18nMessage("i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2", { value0: buildWarnings })}
|
|
||||||
requiredAction={i18nMessage("i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4")}
|
|
||||||
destination={i18nMessage("i18n:govoplan-campaign.review_send_built_messages.6b030946")}
|
|
||||||
documentationTopicId="campaigns.workflow.complete-review"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
|
import { humanize } from "../utils/campaignView";
|
||||||
|
import PaginatedReviewDetails from "./PaginatedReviewDetails";
|
||||||
|
import { groupCampaignValidationIssues, type CampaignValidationIssue } from "./validationIssueGroups";
|
||||||
|
|
||||||
|
export default function ValidationDetails({ issues }: { issues: CampaignValidationIssue[] }) {
|
||||||
|
const groups = useMemo(() => groupCampaignValidationIssues(issues), [issues]);
|
||||||
|
return <>
|
||||||
|
<p className="muted small-note">i18n:govoplan-campaign.validation_grouping_help</p>
|
||||||
|
<PaginatedReviewDetails items={groups} renderPage={page => <DescriptionList variant="inline">
|
||||||
|
{page.map(group => <div key={group.key} data-validation-issue-group={group.key}>
|
||||||
|
<dt>{humanize(group.severity)}</dt>
|
||||||
|
<dd>
|
||||||
|
{group.causes.map((issue, index) => <p key={`cause:${index}`}><strong>{String(issue.message ?? issue.code ?? "")}</strong></p>)}
|
||||||
|
{group.outcomes.map((issue, index) => <p key={`outcome:${index}`}>
|
||||||
|
<span>i18n:govoplan-campaign.validation_policy_outcome</span>{" "}<strong>{String(issue.message ?? issue.code ?? "")}</strong>
|
||||||
|
</p>)}
|
||||||
|
<details><summary>i18n:govoplan-campaign.validation_technical_evidence</summary>
|
||||||
|
<ul className="muted small-note">{group.evidence.map((issue, index) => <li key={index}>
|
||||||
|
{humanize(String(issue.severity ?? "info"))} · {String(issue.path ?? "—")} · {String(issue.code ?? "—")}
|
||||||
|
</li>)}</ul>
|
||||||
|
</details>
|
||||||
|
</dd>
|
||||||
|
</div>)}
|
||||||
|
</DescriptionList>} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { DataGridQueryState } from "@govoplan/core-webui";
|
|||||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||||
import { asArray, asRecord } from "../utils/campaignView";
|
import { asArray, asRecord } from "../utils/campaignView";
|
||||||
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
|
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
|
||||||
|
import { builtMessageState } from "./builtMessageState";
|
||||||
|
|
||||||
type ReviewFilterType = "text" | "integer" | "list";
|
type ReviewFilterType = "text" | "integer" | "list";
|
||||||
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
||||||
@@ -10,7 +11,8 @@ type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
|||||||
export function filterAndSortBuiltMessageRows(
|
export function filterAndSortBuiltMessageRows(
|
||||||
rows: Record<string, unknown>[],
|
rows: Record<string, unknown>[],
|
||||||
query: DataGridQueryState,
|
query: DataGridQueryState,
|
||||||
reviewedKeys: Set<string>
|
reviewedKeys: Set<string>,
|
||||||
|
inspectionComplete = false
|
||||||
): Record<string, unknown>[] {
|
): Record<string, unknown>[] {
|
||||||
const filters = query.filters ?? {};
|
const filters = query.filters ?? {};
|
||||||
const filtered = rows.filter((row, rowIndex) =>
|
const filtered = rows.filter((row, rowIndex) =>
|
||||||
@@ -18,7 +20,7 @@ export function filterAndSortBuiltMessageRows(
|
|||||||
if (!isBuiltMessageQueryColumn(columnId)) return true;
|
if (!isBuiltMessageQueryColumn(columnId)) return true;
|
||||||
if (!filterValue.trim()) return true;
|
if (!filterValue.trim()) return true;
|
||||||
return matchesReviewFilter(
|
return matchesReviewFilter(
|
||||||
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys),
|
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys, inspectionComplete),
|
||||||
filterValue,
|
filterValue,
|
||||||
builtMessageFilterType(columnId)
|
builtMessageFilterType(columnId)
|
||||||
);
|
);
|
||||||
@@ -31,8 +33,8 @@ export function filterAndSortBuiltMessageRows(
|
|||||||
const leftIndex = rows.indexOf(left);
|
const leftIndex = rows.indexOf(left);
|
||||||
const rightIndex = rows.indexOf(right);
|
const rightIndex = rows.indexOf(right);
|
||||||
const result = compareReviewValues(
|
const result = compareReviewValues(
|
||||||
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys),
|
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys, inspectionComplete),
|
||||||
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys)
|
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys, inspectionComplete)
|
||||||
);
|
);
|
||||||
return direction === "desc" ? -result : result;
|
return direction === "desc" ? -result : result;
|
||||||
});
|
});
|
||||||
@@ -54,7 +56,15 @@ export function reviewQueryEquals(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
|
export function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
|
||||||
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
|
const eligibility = asRecord(row.review_decision).eligible;
|
||||||
|
if (typeof eligibility === "boolean") return eligibility;
|
||||||
|
return String(row.validation_status ?? "").toLowerCase() === "needs_review"
|
||||||
|
&& String(row.build_status ?? "built") === "built"
|
||||||
|
&& !asArray(row.issues).some((value) => {
|
||||||
|
const issue = asRecord(value);
|
||||||
|
return ["block", "blocked", "error"].includes(String(issue.behavior ?? "").toLowerCase())
|
||||||
|
|| ["error", "critical", "fatal"].includes(String(issue.severity ?? "").toLowerCase());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
export function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
||||||
@@ -64,9 +74,9 @@ export function storedMessageReviewState(version: CampaignVersionDetail | null):
|
|||||||
issueDecisions: Array<Record<string, unknown>>;
|
issueDecisions: Array<Record<string, unknown>>;
|
||||||
} {
|
} {
|
||||||
const build = asRecord(version?.build_summary);
|
const build = asRecord(version?.build_summary);
|
||||||
const buildToken = String(build.build_token ?? build.built_at ?? "");
|
const buildToken = String(version?.review_build_token ?? build.build_token ?? build.built_at ?? "");
|
||||||
const review = asRecord(asRecord(version?.editor_state).review_send);
|
const review = asRecord(asRecord(version?.editor_state).review_send);
|
||||||
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
|
if (!buildToken || String(review.review_build_token ?? review.build_token ?? "") !== buildToken) {
|
||||||
return {
|
return {
|
||||||
buildToken,
|
buildToken,
|
||||||
inspectionComplete: false,
|
inspectionComplete: false,
|
||||||
@@ -87,6 +97,8 @@ export function storedMessageReviewState(version: CampaignVersionDetail | null):
|
|||||||
export function messageRequiresAttachmentOverrideReason(
|
export function messageRequiresAttachmentOverrideReason(
|
||||||
row: Record<string, unknown>
|
row: Record<string, unknown>
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const decision = asRecord(row.review_decision);
|
||||||
|
if (typeof decision.reason_required === "boolean") return decision.eligible === true && decision.reason_required;
|
||||||
return asArray(row.issues).some((value) => {
|
return asArray(row.issues).some((value) => {
|
||||||
const issue = asRecord(value);
|
const issue = asRecord(value);
|
||||||
return String(issue.behavior ?? "").toLowerCase() === "ask"
|
return String(issue.behavior ?? "").toLowerCase() === "ask"
|
||||||
@@ -95,7 +107,7 @@ export function messageRequiresAttachmentOverrideReason(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
export function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
||||||
return String(row.entry_id ?? row.entry_index ?? index);
|
return String(row.review_key ?? row.entry_id ?? row.entry_index ?? index);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findBuiltMessageIndex(
|
export function findBuiltMessageIndex(
|
||||||
@@ -144,14 +156,15 @@ export function sameBuiltMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isBuiltMessageQueryColumn(columnId: string): boolean {
|
function isBuiltMessageQueryColumn(columnId: string): boolean {
|
||||||
return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId);
|
return ["number", "recipient", "subject", "messageState", "validation", "attachments", "reviewed"].includes(columnId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function builtMessageColumnValue(
|
function builtMessageColumnValue(
|
||||||
columnId: string,
|
columnId: string,
|
||||||
row: Record<string, unknown>,
|
row: Record<string, unknown>,
|
||||||
index: number,
|
index: number,
|
||||||
reviewedKeys: Set<string>
|
reviewedKeys: Set<string>,
|
||||||
|
inspectionComplete = false
|
||||||
): unknown {
|
): unknown {
|
||||||
switch (columnId) {
|
switch (columnId) {
|
||||||
case "number":
|
case "number":
|
||||||
@@ -163,6 +176,8 @@ function builtMessageColumnValue(
|
|||||||
);
|
);
|
||||||
case "subject":
|
case "subject":
|
||||||
return String(row.subject ?? "—");
|
return String(row.subject ?? "—");
|
||||||
|
case "messageState":
|
||||||
|
return builtMessageState(row, index, reviewedKeys, inspectionComplete).state;
|
||||||
case "validation":
|
case "validation":
|
||||||
return String(row.validation_status ?? "unknown");
|
return String(row.validation_status ?? "unknown");
|
||||||
case "attachments":
|
case "attachments":
|
||||||
@@ -179,7 +194,7 @@ function builtMessageColumnValue(
|
|||||||
|
|
||||||
function builtMessageFilterType(columnId: string): ReviewFilterType {
|
function builtMessageFilterType(columnId: string): ReviewFilterType {
|
||||||
if (["number", "attachments"].includes(columnId)) return "integer";
|
if (["number", "attachments"].includes(columnId)) return "integer";
|
||||||
if (["validation", "reviewed"].includes(columnId)) return "list";
|
if (["messageState", "validation", "reviewed"].includes(columnId)) return "list";
|
||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export type BuiltMessageState = "ready" | "needs_review" | "blocked" | "excluded";
|
||||||
|
|
||||||
|
export type BuiltMessageStatePresentation = {
|
||||||
|
state: BuiltMessageState;
|
||||||
|
explanationLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One display/filter model; it never authorizes an exception or a delivery. */
|
||||||
|
export function builtMessageState(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
index: number,
|
||||||
|
reviewedKeys: ReadonlySet<string>,
|
||||||
|
inspectionComplete = false
|
||||||
|
): BuiltMessageStatePresentation {
|
||||||
|
const validation = String(row.validation_status ?? "").toLowerCase();
|
||||||
|
if (validation === "excluded" || validation === "inactive") {
|
||||||
|
return { state: "excluded", explanationLabel: validation === "inactive"
|
||||||
|
? "i18n:govoplan-campaign.message_state_inactive" : "i18n:govoplan-campaign.message_state_excluded" };
|
||||||
|
}
|
||||||
|
const build = String(row.build_status ?? "built").toLowerCase();
|
||||||
|
if (build !== "built") {
|
||||||
|
return { state: "blocked", explanationLabel: ["failed", "error"].includes(build)
|
||||||
|
? "i18n:govoplan-campaign.message_state_build_failed" : "i18n:govoplan-campaign.message_state_not_built" };
|
||||||
|
}
|
||||||
|
const issues = Array.isArray(row.issues) ? row.issues.map(record) : [];
|
||||||
|
if (validation === "blocked" || issues.some((issue) => ["block", "blocked"].includes(String(issue.behavior ?? "").toLowerCase())
|
||||||
|
|| ["error", "critical", "fatal"].includes(String(issue.severity ?? "").toLowerCase()))) {
|
||||||
|
return { state: "blocked", explanationLabel: "i18n:govoplan-campaign.message_state_blocked" };
|
||||||
|
}
|
||||||
|
if (validation === "warning") {
|
||||||
|
// An earlier view/open marker does not replace the final warning gate.
|
||||||
|
return inspectionComplete
|
||||||
|
? { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_warning_accepted" }
|
||||||
|
: { state: "needs_review", explanationLabel: "i18n:govoplan-campaign.message_state_warning_pending" };
|
||||||
|
}
|
||||||
|
if (validation === "needs_review") {
|
||||||
|
const key = String(row.review_key ?? row.entry_id ?? row.entry_index ?? index);
|
||||||
|
const accepted = row.reviewed === true || reviewedKeys.has(key);
|
||||||
|
return accepted
|
||||||
|
? { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_individually_accepted" }
|
||||||
|
: { state: "needs_review", explanationLabel: "i18n:govoplan-campaign.message_state_individual_pending" };
|
||||||
|
}
|
||||||
|
if (validation === "ready") {
|
||||||
|
return { state: "ready", explanationLabel: "i18n:govoplan-campaign.message_state_automatic" };
|
||||||
|
}
|
||||||
|
return { state: "blocked", explanationLabel: "i18n:govoplan-campaign.message_state_unknown" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
export const BULK_MESSAGE_REVIEW_LIMIT = 200;
|
||||||
|
|
||||||
|
export type BulkMessageReviewRow = {
|
||||||
|
jobId: string;
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkMessageReviewGroup = {
|
||||||
|
categoryKey: string;
|
||||||
|
issueCodes: string[];
|
||||||
|
reasonRequired: boolean;
|
||||||
|
messages: BulkMessageReviewRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BulkMessageReviewSelection = {
|
||||||
|
buildToken: string;
|
||||||
|
categoryKey: string;
|
||||||
|
jobIds: string[];
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueLabels: Record<string, string> = {
|
||||||
|
missing_required_attachment: "i18n:govoplan-campaign.bulk_review_missing_required",
|
||||||
|
missing_optional_attachment: "i18n:govoplan-campaign.bulk_review_missing_optional",
|
||||||
|
missing_attachment_coverage: "i18n:govoplan-campaign.bulk_review_no_attachments",
|
||||||
|
ambiguous_attachment_match: "i18n:govoplan-campaign.bulk_review_multiple_matches",
|
||||||
|
duplicate_attachment_reuse: "i18n:govoplan-campaign.bulk_review_shared_attachment",
|
||||||
|
attachment_override_required: "i18n:govoplan-campaign.bulk_review_attachment_exception",
|
||||||
|
attachment_warning: "i18n:govoplan-campaign.bulk_review_attachment_exception",
|
||||||
|
attachment_match_empty: "i18n:govoplan-campaign.bulk_review_missing_optional",
|
||||||
|
unsent_attachment_files: "i18n:govoplan-campaign.bulk_review_unused_files",
|
||||||
|
residual_attachment_disposition: "i18n:govoplan-campaign.bulk_review_unused_files",
|
||||||
|
missing_email: "i18n:govoplan-campaign.bulk_review_missing_address",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function bulkReviewIssueLabel(code: string): string {
|
||||||
|
return issueLabels[code] ?? "i18n:govoplan-campaign.bulk_review_other_category";
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as Record<string, unknown> : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only the backend may offer a category for accepting frozen review evidence. */
|
||||||
|
export function bulkMessageReviewGroups(rows: Record<string, unknown>[]): BulkMessageReviewGroup[] {
|
||||||
|
const groups = new Map<string, BulkMessageReviewGroup>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const decision = record(row.review_decision);
|
||||||
|
const jobId = String(row.id ?? "").trim();
|
||||||
|
const categoryKey = String(decision.category_key ?? "").trim();
|
||||||
|
if (decision.eligible !== true || !jobId || !categoryKey || seen.has(jobId)
|
||||||
|
|| row.reviewed === true || row.build_status !== "built"
|
||||||
|
|| row.validation_status !== "needs_review") continue;
|
||||||
|
const issues = Array.isArray(row.issues) ? row.issues.map(record) : [];
|
||||||
|
if (issues.some((issue) => ["block", "blocked", "error"].includes(String(issue.behavior ?? "").toLowerCase())
|
||||||
|
|| ["error", "critical", "fatal"].includes(String(issue.severity ?? "").toLowerCase()))) continue;
|
||||||
|
seen.add(jobId);
|
||||||
|
const issueCodes = Array.isArray(decision.issue_codes)
|
||||||
|
? decision.issue_codes.filter((code): code is string => typeof code === "string" && Boolean(code.trim())) : [];
|
||||||
|
const group = groups.get(categoryKey) ?? {
|
||||||
|
categoryKey, issueCodes: [...new Set(issueCodes)].sort(), reasonRequired: false, messages: []
|
||||||
|
};
|
||||||
|
group.reasonRequired ||= decision.reason_required === true;
|
||||||
|
const recipients = record(row.resolved_recipients).to;
|
||||||
|
const recipient = Array.isArray(recipients)
|
||||||
|
? recipients.map((value) => typeof value === "string" ? value
|
||||||
|
: String(record(value).email ?? record(value).address ?? "")).filter(Boolean).join(", ") : "";
|
||||||
|
group.messages.push({ jobId, recipient: recipient || String(row.recipient_email ?? row.entry_id ?? jobId), subject: String(row.subject ?? "") });
|
||||||
|
groups.set(categoryKey, group);
|
||||||
|
}
|
||||||
|
return [...groups.values()].map((group) => ({ ...group, messages: group.messages.sort((left, right) => left.jobId.localeCompare(right.jobId)) }))
|
||||||
|
.sort((left, right) => left.categoryKey.localeCompare(right.categoryKey));
|
||||||
|
}
|
||||||
@@ -1,41 +1,43 @@
|
|||||||
import { Check, Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TableActionGroup,
|
TableActionGroup,
|
||||||
|
i18nMessage,
|
||||||
type DataGridColumn,
|
type DataGridColumn,
|
||||||
type DataGridListOption
|
type DataGridListOption
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import type { MockMailboxMessage } from "../../../api/mail";
|
import type { MockMailboxMessage } from "../../../api/mail";
|
||||||
import type { CampaignMessagePreviewAttachment } from "../components/MessagePreviewOverlay";
|
import type { CampaignMessagePreviewAttachment } from "../components/MessagePreviewOverlay";
|
||||||
import { asArray, asRecord, stringifyPreview } from "../utils/campaignView";
|
import { asArray, asRecord, stringifyPreview } from "../utils/campaignView";
|
||||||
import { builtMessageKey } from "./builtMessageQuery";
|
import { SEND_STATUS_OPTIONS, IMAP_STATUS_OPTIONS, deliveryStatusLabel } from "../utils/deliveryStatusOptions";
|
||||||
|
import { builtMessageState } from "./builtMessageState";
|
||||||
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
|
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
|
||||||
|
|
||||||
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
|
const MESSAGE_STATE_OPTIONS: DataGridListOption[] = [
|
||||||
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" },
|
{ value: "ready", label: "i18n:govoplan-campaign.message_state_ready" },
|
||||||
{ value: "warning", label: "i18n:govoplan-campaign.warning.e9c45563" },
|
{ value: "needs_review", label: "i18n:govoplan-campaign.message_state_needs_review" },
|
||||||
{ value: "needs_review", label: "i18n:govoplan-campaign.needs_review.33a506cf" },
|
{ value: "blocked", label: "i18n:govoplan-campaign.message_state_blocked_label" },
|
||||||
{ value: "blocked", label: "i18n:govoplan-campaign.blocked.99613c74" },
|
{ value: "excluded", label: "i18n:govoplan-campaign.message_state_excluded_label" }];
|
||||||
{ value: "excluded", label: "i18n:govoplan-campaign.excluded.9804952b" }];
|
|
||||||
|
|
||||||
|
|
||||||
export function builtMessageColumns(
|
export function builtMessageColumns(
|
||||||
openMessage: (row: Record<string, unknown>) => void,
|
openMessage: (row: Record<string, unknown>) => void,
|
||||||
reviewedKeys: Set<string>)
|
reviewedKeys: Set<string>,
|
||||||
|
inspectionComplete = false)
|
||||||
: DataGridColumn<Record<string, unknown>>[] {
|
: DataGridColumn<Record<string, unknown>>[] {
|
||||||
return [
|
return [
|
||||||
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
|
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", value: (row, index) => Number(row.entry_index ?? index + 1) },
|
||||||
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
|
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—") },
|
||||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||||
{
|
{
|
||||||
id: "validation",
|
id: "messageState",
|
||||||
header: "i18n:govoplan-campaign.validation.dd74d182",
|
header: "i18n:govoplan-campaign.message_state_heading",
|
||||||
width: 145,
|
width: 145,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
columnType: "from-list",
|
columnType: "from-list",
|
||||||
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
|
list: { options: MESSAGE_STATE_OPTIONS, display: "pill" },
|
||||||
value: (row) => String(row.validation_status ?? "unknown")
|
value: (row, index) => builtMessageState(row, index, reviewedKeys, inspectionComplete).state
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "postboxTargets",
|
id: "postboxTargets",
|
||||||
@@ -55,7 +57,8 @@ reviewedKeys: Set<string>)
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
|
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
|
||||||
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted">—</span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
|
{ id: "stateExplanation", header: "i18n:govoplan-campaign.message_state_explanation_heading", width: "minmax(220px, 1fr)", maxWidth: 480, resizable: true,
|
||||||
|
value: (row, index) => builtMessageState(row, index, reviewedKeys, inspectionComplete).explanationLabel },
|
||||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
|
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -107,8 +110,8 @@ export function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<voi
|
|||||||
return [
|
return [
|
||||||
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
|
{ id: "recipient", header: "i18n:govoplan-campaign.recipient.90343260", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
|
||||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
|
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
|
||||||
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
|
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} label={deliveryStatusLabel(String(row.send_status ?? "info"))} />, value: (row) => String(row.send_status ?? "-") },
|
||||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
|
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} label={deliveryStatusLabel(String(row.imap_status ?? "info"))} />, value: (row) => String(row.imap_status ?? "-") },
|
||||||
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", maxWidth: 720, resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
|
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", maxWidth: 720, resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
|
||||||
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
|
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
|
||||||
|
|
||||||
@@ -153,7 +156,10 @@ export function synchronousSendReason(option: Record<string, unknown>): string {
|
|||||||
const configuredMessage = String(option.message ?? "").trim();
|
const configuredMessage = String(option.message ?? "").trim();
|
||||||
if (configuredMessage) return configuredMessage;
|
if (configuredMessage) return configuredMessage;
|
||||||
switch (String(option.reason ?? "")) {
|
switch (String(option.reason ?? "")) {
|
||||||
case "recipient_limit_exceeded":return "i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a";
|
case "recipient_limit_exceeded":return i18nMessage("i18n:govoplan-campaign.synchronous_limit_explanation", {
|
||||||
|
value0: Number(option.eligible_recipient_job_count ?? 0),
|
||||||
|
value1: Number(asRecord(option.policy).max_recipient_jobs ?? 0)
|
||||||
|
});
|
||||||
case "no_eligible_recipient_jobs":return "i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c";
|
case "no_eligible_recipient_jobs":return "i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c";
|
||||||
case "version_not_ready":return "i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc";
|
case "version_not_ready":return "i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc";
|
||||||
case "policy_configuration_invalid":return "i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8";
|
case "policy_configuration_invalid":return "i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8";
|
||||||
@@ -168,6 +174,7 @@ export function deliveryControlProgressMessage(action: "pause" | "resume" | "can
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deliveryPolicySourceLabel(source: string): string {
|
export function deliveryPolicySourceLabel(source: string): string {
|
||||||
|
if (source === "system" || source === "system_ceiling") return "i18n:govoplan-campaign.system_delivery_policy_source";
|
||||||
if (source === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
|
if (source === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
|
||||||
if (source === "deployment") return "i18n:govoplan-campaign.deployment.327a55f8";
|
if (source === "deployment") return "i18n:govoplan-campaign.deployment.327a55f8";
|
||||||
if (source === "deployment_default") return "i18n:govoplan-campaign.deployment_default.aa39f7b4";
|
if (source === "deployment_default") return "i18n:govoplan-campaign.deployment_default.aa39f7b4";
|
||||||
|
|||||||
@@ -47,3 +47,27 @@ export function calculateBuildReviewProgress(input: BuildReviewProgressInput): B
|
|||||||
remaining: individualRemaining + groupRemaining
|
remaining: individualRemaining + groupRemaining
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Summary match counts describe inputs; they are not fresh review gates. */
|
||||||
|
export function attachmentDeliveryPreflight(input: {
|
||||||
|
migrationRequired: boolean;
|
||||||
|
hasBuild: boolean;
|
||||||
|
reviewLoaded: boolean;
|
||||||
|
blocking: number;
|
||||||
|
remaining: number;
|
||||||
|
inspectionComplete: boolean;
|
||||||
|
missing: number;
|
||||||
|
ambiguous: number;
|
||||||
|
}): { state: "ready" | "warning" | "blocked" | "info"; detail: string } {
|
||||||
|
if (input.migrationRequired) return { state: "blocked", detail: "i18n:govoplan-campaign.attachment_checks_await_mail_migration" };
|
||||||
|
if (!input.hasBuild) return { state: "info", detail: "i18n:govoplan-campaign.attachment_preflight_build_first" };
|
||||||
|
if (!input.reviewLoaded) return { state: "info", detail: "i18n:govoplan-campaign.attachment_preflight_loading" };
|
||||||
|
if (input.blocking > 0) return { state: "blocked", detail: "i18n:govoplan-campaign.attachment_preflight_blocked" };
|
||||||
|
if (input.inspectionComplete || input.remaining === 0) {
|
||||||
|
return { state: "ready", detail: "i18n:govoplan-campaign.attachment_preflight_satisfied" };
|
||||||
|
}
|
||||||
|
if (input.missing + input.ambiguous > 0) {
|
||||||
|
return { state: "warning", detail: "i18n:govoplan-campaign.attachment_preflight_review" };
|
||||||
|
}
|
||||||
|
return { state: "info", detail: "i18n:govoplan-campaign.attachment_preflight_pending" };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export type CampaignValidationIssue = Record<string, unknown>;
|
||||||
|
export type CampaignValidationIssueGroup = {
|
||||||
|
key: string;
|
||||||
|
severity: string;
|
||||||
|
causes: CampaignValidationIssue[];
|
||||||
|
outcomes: CampaignValidationIssue[];
|
||||||
|
evidence: CampaignValidationIssue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MISSING_RULE_CODES = new Set(["missing_required_attachment", "missing_optional_attachment", "attachment_match_empty"]);
|
||||||
|
function entryPath(issue: CampaignValidationIssue): string | null {
|
||||||
|
return String(issue.path ?? "").match(/^(\/entries\/[^/]+)(?:\/attachments\/\d+)?$/)?.[1] ?? null;
|
||||||
|
}
|
||||||
|
function severityRank(issue: CampaignValidationIssue): number {
|
||||||
|
return ({ error: 3, warning: 2, info: 1 } as Record<string, number>)[String(issue.severity ?? "")] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Join a recipient's missing-rule cause with its no-attachment policy outcome.
|
||||||
|
* Keep every original record; unrelated validation problems stay independent.
|
||||||
|
*/
|
||||||
|
export function groupCampaignValidationIssues(issues: CampaignValidationIssue[]): CampaignValidationIssueGroup[] {
|
||||||
|
const coveragePaths = new Set(issues.filter(issue => issue.code === "missing_attachment_coverage")
|
||||||
|
.map(entryPath).filter((path): path is string => Boolean(path)));
|
||||||
|
const groups = new Map<string, CampaignValidationIssueGroup>();
|
||||||
|
issues.forEach((issue, index) => {
|
||||||
|
const path = entryPath(issue);
|
||||||
|
const coverage = issue.code === "missing_attachment_coverage";
|
||||||
|
const related = path && coveragePaths.has(path) && (coverage || MISSING_RULE_CODES.has(String(issue.code ?? "")));
|
||||||
|
const key = related ? `attachment-coverage:${path}` : `issue:${index}`;
|
||||||
|
const group = groups.get(key) ?? { key, severity: String(issue.severity ?? "info"), causes: [], outcomes: [], evidence: [] };
|
||||||
|
if (severityRank(issue) > severityRank({ severity: group.severity })) group.severity = String(issue.severity);
|
||||||
|
group.evidence.push(issue);
|
||||||
|
(coverage && related ? group.outcomes : group.causes).push(issue);
|
||||||
|
groups.set(key, group);
|
||||||
|
});
|
||||||
|
return [...groups.values()];
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { DataGridListOption } from "@govoplan/core-webui";
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
not_queued: "Not queued", skipped: "Excluded", queued: "Queued", claimed: "Claimed", sending: "Sending",
|
||||||
|
smtp_accepted: "SMTP accepted", postbox_accepted: "Postbox accepted", print_accepted: "Print accepted",
|
||||||
|
delivered: "Delivered", partially_accepted: "Partially accepted", sent: "Sent", outcome_unknown: "Outcome uncertain",
|
||||||
|
failed_temporary: "Temporary failure", failed_permanent: "Permanent failure", cancelled: "Cancelled",
|
||||||
|
not_requested: "Not requested", pending: "Pending", appending: "Copying to Sent", appended: "Copied to Sent",
|
||||||
|
failed: "Failed", ready: "Ready", accepted: "Accepted", delivering: "Delivering", accepted_vacant: "Accepted (vacant)",
|
||||||
|
rejected_temporary: "Temporarily rejected", rejected_permanent: "Permanently rejected"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deliveryStatusLabel(status: string): string | undefined {
|
||||||
|
return Object.prototype.hasOwnProperty.call(labels, status) ? `i18n:govoplan-campaign.delivery_status_${status}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOptions(values: string[]): DataGridListOption[] {
|
||||||
|
return values.map((value) => ({ value, label: deliveryStatusLabel(value) ?? value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SEND_STATUS_OPTIONS = statusOptions([
|
||||||
|
"not_queued", "skipped", "queued", "claimed", "sending", "smtp_accepted", "postbox_accepted", "print_accepted",
|
||||||
|
"delivered", "partially_accepted", "sent", "outcome_unknown", "failed_temporary", "failed_permanent", "cancelled"
|
||||||
|
]);
|
||||||
|
export const IMAP_STATUS_OPTIONS = statusOptions([
|
||||||
|
"not_requested", "pending", "appending", "appended", "outcome_unknown", "failed", "skipped"
|
||||||
|
]);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** Review and approval evidence is readable, but is never a client mutation. */
|
||||||
|
export function clientCampaignEditorState(
|
||||||
|
value: Record<string, unknown> | null | undefined
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const key of ["created_from", "field_overrides", "opt_ins"] as const) {
|
||||||
|
if (value && Object.prototype.hasOwnProperty.call(value, key)) {
|
||||||
|
result[key] = value[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function campaignVersionUpdateForRequest<T extends {
|
||||||
|
editor_state?: Record<string, unknown> | null;
|
||||||
|
}>(payload: T): T {
|
||||||
|
// Preserve omission/null: they mean “leave metadata unchanged” on the server.
|
||||||
|
if (payload.editor_state == null) return payload;
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
editor_state: clientCampaignEditorState(payload.editor_state)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,23 @@
|
|||||||
|
export function campaignMailProfileListOptions(view: "settings" | "policy", campaignId: string) {
|
||||||
|
// Policy management needs its administrative catalogue; delivery selection
|
||||||
|
// needs only profiles authorized for this particular campaign. Do not couple
|
||||||
|
// these independent reads or let a policy catalogue failure empty the picker.
|
||||||
|
return view === "policy"
|
||||||
|
? { includeInactive: true, campaignId: undefined }
|
||||||
|
: { includeInactive: false, campaignId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function campaignMailReferencesUnchanged(
|
||||||
|
current: Record<string, unknown>, candidate: Record<string, unknown>
|
||||||
|
): boolean {
|
||||||
|
const before = isRecord(current.server) ? current.server : {};
|
||||||
|
const after = isRecord(candidate.server) ? candidate.server : {};
|
||||||
|
const keys = Object.keys(before);
|
||||||
|
return keys.length === Object.keys(after).length && keys.every((key) => (
|
||||||
|
Object.prototype.hasOwnProperty.call(after, key) && after[key] === before[key]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
|
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
const server = isRecord(value.server) ? value.server : {};
|
const server = isRecord(value.server) ? value.server : {};
|
||||||
const profileId = textId(server.mail_profile_id);
|
const profileId = textId(server.mail_profile_id);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" a
|
|||||||
export type ReportGridShortcutId =
|
export type ReportGridShortcutId =
|
||||||
| "all"
|
| "all"
|
||||||
| "smtp_accepted"
|
| "smtp_accepted"
|
||||||
|
| "smtp_active"
|
||||||
|
| "smtp_queued"
|
||||||
| "postbox_accepted"
|
| "postbox_accepted"
|
||||||
| "print_accepted"
|
| "print_accepted"
|
||||||
| "failed"
|
| "failed"
|
||||||
@@ -17,11 +19,16 @@ export type ReportGridShortcutId =
|
|||||||
| "cancelled"
|
| "cancelled"
|
||||||
| "imap_appended"
|
| "imap_appended"
|
||||||
| "imap_failed"
|
| "imap_failed"
|
||||||
|
| "imap_pending"
|
||||||
|
| "imap_active"
|
||||||
|
| "imap_unknown"
|
||||||
| "imap_skipped";
|
| "imap_skipped";
|
||||||
|
|
||||||
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
|
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
|
||||||
all: {},
|
all: {},
|
||||||
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
|
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
|
||||||
|
smtp_active: { send: listFilter(["claimed", "sending"]) },
|
||||||
|
smtp_queued: { send: listFilter(["queued"]) },
|
||||||
postbox_accepted: { send: listFilter(["postbox_accepted"]) },
|
postbox_accepted: { send: listFilter(["postbox_accepted"]) },
|
||||||
print_accepted: { send: listFilter(["print_accepted"]) },
|
print_accepted: { send: listFilter(["print_accepted"]) },
|
||||||
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
|
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
|
||||||
@@ -31,6 +38,9 @@ const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string,
|
|||||||
cancelled: { send: listFilter(["cancelled"]) },
|
cancelled: { send: listFilter(["cancelled"]) },
|
||||||
imap_appended: { imap: listFilter(["appended"]) },
|
imap_appended: { imap: listFilter(["appended"]) },
|
||||||
imap_failed: { imap: listFilter(["failed"]) },
|
imap_failed: { imap: listFilter(["failed"]) },
|
||||||
|
imap_pending: { imap: listFilter(["pending"]) },
|
||||||
|
imap_active: { imap: listFilter(["appending"]) },
|
||||||
|
imap_unknown: { imap: listFilter(["outcome_unknown"]) },
|
||||||
imap_skipped: { imap: listFilter(["skipped"]) }
|
imap_skipped: { imap: listFilter(["skipped"]) }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function CreateWizard({ settings, campaignId }: {settings: ApiSet
|
|||||||
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, reload } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
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, dirty, saveState, patch, saveDraft } = useCampaignDraftEditor({
|
const { draft, dirty, saving, saveState, patch, saveDraft } = useCampaignDraftEditor({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
version,
|
version,
|
||||||
@@ -132,7 +132,7 @@ export default function CreateWizard({ settings, campaignId }: {settings: ApiSet
|
|||||||
</Card>
|
</Card>
|
||||||
<div className="wizard-footer">
|
<div className="wizard-footer">
|
||||||
<Button onClick={previousStep}>i18n:govoplan-campaign.back.b52b36b7</Button>
|
<Button onClick={previousStep}>i18n:govoplan-campaign.back.b52b36b7</Button>
|
||||||
<Button onClick={() => saveDraft("manual")} disabled={!dirty}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
<Button onClick={() => saveDraft("manual")} disabled={!dirty || saving}>{saving ? "i18n:govoplan-campaign.saving.56a2285c" : dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
||||||
<Button onClick={validateCurrentStep}>i18n:govoplan-campaign.validate_step.6a8b527c</Button>
|
<Button onClick={validateCurrentStep}>i18n:govoplan-campaign.validate_step.6a8b527c</Button>
|
||||||
<Button variant="primary" onClick={nextStep}>i18n:govoplan-campaign.continue.2e026239</Button>
|
<Button variant="primary" onClick={nextStep}>i18n:govoplan-campaign.continue.2e026239</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,176 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
|||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = {
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
"en": {
|
"en": {
|
||||||
|
"i18n:govoplan-campaign.report_claim_still_active": "Processing is still active or its lease has not expired. Recovery is unavailable; wait and reload the saved status.",
|
||||||
|
"i18n:govoplan-campaign.report_claim_owner_unconfirmed": "The server cannot yet confirm that the previous process stopped. Recovery stays blocked to prevent duplicate delivery; ask an administrator to check the process and its lease.",
|
||||||
|
"i18n:govoplan-campaign.report_recipients_to": "To",
|
||||||
|
"i18n:govoplan-campaign.report_recipients_cc": "Cc",
|
||||||
|
"i18n:govoplan-campaign.report_recipients_bcc": "Bcc",
|
||||||
|
"i18n:govoplan-campaign.report_retry_now": "Retry now",
|
||||||
|
"i18n:govoplan-campaign.report_send_unattempted_now": "Send unattempted message now",
|
||||||
|
"i18n:govoplan-campaign.report_retry_page_now": "Retry failed messages on this page now ({value0})",
|
||||||
|
"i18n:govoplan-campaign.report_send_page_now": "Send unattempted messages on this page now ({value0})",
|
||||||
|
"i18n:govoplan-campaign.report_queue_retry_workers": "Queue temporary failures for workers",
|
||||||
|
"i18n:govoplan-campaign.report_queue_unattempted_workers": "Queue unattempted messages for workers",
|
||||||
|
"i18n:govoplan-campaign.report_workers_unavailable": "Background workers are disabled. Use the explicit “now” actions for synchronous recovery.",
|
||||||
|
"i18n:govoplan-campaign.report_inline_scope_help": "“Now” actions affect only the indicated jobs on the current filtered page, within the configured synchronous limit. Previously accepted or uncertain deliveries are never automatically resent.",
|
||||||
|
"i18n:govoplan-campaign.report_inline_result": "Delivery request finished: {value0} attempted, {value1} accepted, {value2} failed, {value3} unknown, {value4} remaining. Inspect the refreshed delivery states before continuing.",
|
||||||
|
"i18n:govoplan-campaign.report_acknowledged_refresh_failed": "The action was acknowledged, but refreshing its display failed. Reload the report; do not repeat the action blindly.",
|
||||||
|
"i18n:govoplan-campaign.report_reconcile_title": "Record verified delivery outcome",
|
||||||
|
"i18n:govoplan-campaign.report_recover_claim_title": "Recover interrupted processing",
|
||||||
|
"i18n:govoplan-campaign.report_recover_claim_confirm": "Mark outcome for investigation",
|
||||||
|
"i18n:govoplan-campaign.report_recover_claim_help": "The server has identified a stopped owner and an expired processing claim. Record your evidence to mark this attempt as uncertain. This does not send anything or decide whether delivery succeeded; verify that separately before recording an outcome.",
|
||||||
|
"i18n:govoplan-campaign.report_evidence_note": "Evidence note",
|
||||||
|
"i18n:govoplan-campaign.report_recording_evidence": "Recording evidence…",
|
||||||
|
"i18n:govoplan-campaign.report_record_accepted": "Record SMTP acceptance",
|
||||||
|
"i18n:govoplan-campaign.report_record_not_sent": "Record message as not sent",
|
||||||
|
"i18n:govoplan-campaign.report_accepted_evidence_help": "Confirm only after verifying acceptance or the saved IMAP copy in the mail server logs or mailbox. Explain what you checked. Recording evidence does not send the message again.",
|
||||||
|
"i18n:govoplan-campaign.report_not_sent_evidence_help": "Confirm only when you have evidence that the message was not accepted, or the IMAP copy was not saved. An incorrect decision could permit a duplicate. Retrying remains a separate explicit action.",
|
||||||
|
"i18n:govoplan-campaign.report_claim_recovered": "Interrupted processing is now marked as uncertain. Inspect provider evidence and record the verified outcome separately.",
|
||||||
|
"i18n:govoplan-campaign.report_evidence_recorded": "The verified outcome was recorded. Any retry remains a separate explicit action.",
|
||||||
|
"i18n:govoplan-campaign.report_recover_smtp_claim": "Recover interrupted SMTP processing",
|
||||||
|
"i18n:govoplan-campaign.report_recover_imap_claim": "Recover interrupted IMAP processing",
|
||||||
|
"i18n:govoplan-campaign.report_record_imap_appended": "Record IMAP copy as saved",
|
||||||
|
"i18n:govoplan-campaign.report_record_imap_not_appended": "Record IMAP copy as not saved",
|
||||||
|
"i18n:govoplan-campaign.delivery_policy_admin": "Campaign delivery",
|
||||||
|
"i18n:govoplan-campaign.configure_sending_limit": "Configure sending limit",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_smtp": "Sending messages",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_not_queued": "Not queued",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_skipped": "Excluded",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_queued": "Queued",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_claimed": "Claimed",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_sending": "Sending",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_smtp_accepted": "SMTP accepted",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_postbox_accepted": "Postbox accepted",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_print_accepted": "Print accepted",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_delivered": "Delivered",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_partially_accepted": "Partially accepted",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_sent": "Sent",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_outcome_unknown": "Outcome uncertain",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_failed_temporary": "Temporary failure",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_failed_permanent": "Permanent failure",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_cancelled": "Cancelled",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_not_requested": "Not requested",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_pending": "Pending",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_appending": "Copying to Sent",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_appended": "Copied to Sent",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_failed": "Failed",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_ready": "Ready",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_accepted": "Accepted",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_delivering": "Delivering",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_accepted_vacant": "Accepted (vacant)",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_rejected_temporary": "Temporarily rejected",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_rejected_permanent": "Permanently rejected",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_imap": "Copying messages to Sent",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_processed": "{value0}/{value1} processed",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_loading": "Loading saved progress…",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_running": "Processing is running. Only these saved counters refresh; the campaign page stays unchanged.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_finished": "The request finished. Check the totals below for remaining work or problems.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_interrupted": "The connection ended, but processing may still be running. These saved counters continue refreshing. Do not resend or append again until the outcome is clear.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_stale": "Progress could not be refreshed. The last available counters are retained; checking again shortly. This does not mean processing failed.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_accepted": "SMTP accepted",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_appended": "Copied to Sent",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_active": "In progress",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_pending": "Pending",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_failed": "Failed",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_unknown": "Outcome uncertain",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_excluded": "Excluded",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_paused": "Paused",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_cancelled": "Cancelled",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_updated": "Last checked",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_scope": "Counts cover this campaign version. Processed includes successful, failed, uncertain and cancelled results; excluded messages are outside the total. In-progress messages are counted separately.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_refresh_failed": "Delivery details could not be refreshed. Saved results are retained; this does not mean processing failed. Use Reload to refresh the display, not to repeat the operation.",
|
||||||
|
"i18n:govoplan-campaign.delivery_smtp_finished_summary": "Send finished. SMTP accepted {value0} message(s), failed {value1}, outcome unknown {value2}, paused {value3}.",
|
||||||
|
"i18n:govoplan-campaign.delivery_imap_finished_summary": "IMAP append processed {value0} job(s): appended {value1}, failed {value2}.",
|
||||||
|
"i18n:govoplan-campaign.delivery_imap_queued_summary": "Queued {value0} pending IMAP append job(s).",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_before_lock": "Link all required files before locking the campaign. Once locked, the file selection cannot be changed.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_locked_warning": "This version is locked: file linking is unavailable. Unlinked files are not part of its frozen attachment snapshot. Use an editable version to link files, then validate, build and review it before sending.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_loading_reason": "File matches are still loading. Wait before linking or locking.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_failed_reason": "File matches could not be checked. Refresh the attachment preview before locking.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_lock_failed": "Locking was stopped because the current file-link preview could not be checked. Refresh it and try again.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_readonly_reason": "This version is read-only; link files in an editable version.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_all_done": "All matched files are already linked.",
|
||||||
|
"i18n:govoplan-campaign.system_delivery_policy_source": "System delivery settings",
|
||||||
|
"i18n:govoplan-campaign.message_state_filter_help": "State uses the saved decisions for this build: Ready, Needs review, Blocked or Excluded. The review-candidates button sets this same State filter; the explanation column tells you why a message has that state.",
|
||||||
|
"i18n:govoplan-campaign.validation_grouping_help": "Related attachment-rule causes and their policy outcome appear together. Technical details retain each original validation record; the page count refers to grouped conditions.",
|
||||||
|
"i18n:govoplan-campaign.validation_policy_outcome": "Policy outcome:",
|
||||||
|
"i18n:govoplan-campaign.validation_technical_evidence": "Technical details",
|
||||||
|
"i18n:govoplan-campaign.repeated_file_uses": "{value0} uses",
|
||||||
|
"i18n:govoplan-campaign.message_state_heading": "Message state",
|
||||||
|
"i18n:govoplan-campaign.message_state_ready": "Ready",
|
||||||
|
"i18n:govoplan-campaign.message_state_needs_review": "Needs review",
|
||||||
|
"i18n:govoplan-campaign.message_state_blocked_label": "Blocked",
|
||||||
|
"i18n:govoplan-campaign.message_state_excluded_label": "Excluded",
|
||||||
|
"i18n:govoplan-campaign.message_state_explanation_heading": "Why this state?",
|
||||||
|
"i18n:govoplan-campaign.message_state_automatic": "Build passed; no manual decision is required.",
|
||||||
|
"i18n:govoplan-campaign.message_state_individual_pending": "The build requires an explicit review decision for this message.",
|
||||||
|
"i18n:govoplan-campaign.message_state_individually_accepted": "Build exception accepted with a saved review decision.",
|
||||||
|
"i18n:govoplan-campaign.message_state_warning_pending": "Non-blocking build warning; acknowledge it with Complete review.",
|
||||||
|
"i18n:govoplan-campaign.message_state_warning_accepted": "Build warning acknowledged when review was completed.",
|
||||||
|
"i18n:govoplan-campaign.message_state_excluded": "Excluded by configured policy; this message will not be sent.",
|
||||||
|
"i18n:govoplan-campaign.message_state_inactive": "The recipient is inactive; this message will not be sent.",
|
||||||
|
"i18n:govoplan-campaign.message_state_build_failed": "The message could not be built. Correct the source and rebuild.",
|
||||||
|
"i18n:govoplan-campaign.message_state_not_built": "No completed message build is available.",
|
||||||
|
"i18n:govoplan-campaign.message_state_blocked": "A blocking condition must be corrected and rebuilt; it cannot be accepted.",
|
||||||
|
"i18n:govoplan-campaign.message_state_unknown": "The build state is unavailable. Reload or rebuild before continuing.",
|
||||||
|
"i18n:govoplan-campaign.synchronous_limit_explanation": "This campaign has {value0} eligible messages; ‘Send now’ allows at most {value1} in one request. Larger campaigns use background delivery.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_build_first": "Build the messages to evaluate their exact attachment requirements.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_loading": "Loading review evidence for the current message build.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_blocked": "The current build still has blocking conditions. Correct them before delivery; review decisions cannot override blockers.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_satisfied": "Attachment conditions are satisfied or already accepted for this build. No further attachment approval is required.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_review": "Attachment exceptions still need a decision in Build and Review. Saved decisions carry forward here automatically.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_pending": "No missing or ambiguous matches are reported. Complete the remaining build review before delivery.",
|
||||||
|
"i18n:govoplan-campaign.review_save_pending": "A review decision is being saved. Please wait.",
|
||||||
|
"i18n:govoplan-campaign.review_build_changed": "The message build has changed. Reload the review before making another decision; previous approvals cannot apply to new messages.",
|
||||||
|
"i18n:govoplan-campaign.review_decision_saved": "Review decision saved. You can leave and continue later.",
|
||||||
|
"i18n:govoplan-campaign.review_reason_required": "Explain why it is acceptable to continue without these attachments.",
|
||||||
|
"i18n:govoplan-campaign.review_reason_label": "Why is this acceptable?",
|
||||||
|
"i18n:govoplan-campaign.review_note_label": "Review note (optional)",
|
||||||
|
"i18n:govoplan-campaign.review_reason_help": "Saved with this message when you accept. The next message opens automatically; you do not need to finish the whole review to keep your progress.",
|
||||||
|
"i18n:govoplan-campaign.review_saving_decision": "Saving decision…",
|
||||||
|
"i18n:govoplan-campaign.review_accept_next": "Accept and continue",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_title": "Accept similar review conditions",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_missing_required": "Required attachment was not found",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_missing_optional": "Optional attachment was not found",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_no_attachments": "Message has no attachments",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_multiple_matches": "More than one file matched a single-file rule",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_shared_attachment": "The same attachment is used for several recipients",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_attachment_exception": "Attachment rule requires an exception",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_unused_files": "Some attachment files have no recipient",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_missing_address": "Recipient email address is missing",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_technical_codes": "Technical issue codes",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_scope": "Choose one server-verified issue category among currently loaded matching messages. Only the recipients selected here will be accepted. Blockers, already reviewed messages, intentional policy exclusions, and allowed zero-match attachment rules are not included. This records review evidence; it does not send messages or complete the review.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_category": "Review condition category",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_other_category": "Other review conditions",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_empty": "No matching messages are eligible for a grouped review decision.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_subset": "Showing {value0} of {value1} matching unreviewed messages in this category; {value2} selected.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_limit": "A decision contains at most 200 messages. After saving this selection, reopen the dialog to review the remaining messages.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_select_all": "Select all shown",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_select_none": "Clear selection",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_recipients": "Messages included in this decision",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_reason_required": "Common reason for accepting attachment exceptions (required)",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_reason": "Common review note",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_reason_help": "This note applies to every selected message and is stored with each message's frozen build evidence. Review the recipients and conditions before accepting.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_accept_count": "Accept {value0} selected messages",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_saving": "Saving review decisions…",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_stale": "The build changed while this dialog was open. Close it and inspect the current build before accepting any conditions.",
|
||||||
|
"i18n:govoplan-campaign.file_chooser_unavailable": "The Files browser is currently unavailable. Your attachment settings are unchanged. If it does not return after the module finishes loading, reload the page after saving any pending changes.",
|
||||||
|
"i18n:govoplan-campaign.choose_available_attachment_source": "The attachment's source is no longer available. Select an available base path in this row before choosing a file or pattern.",
|
||||||
|
"i18n:govoplan-campaign.inherit_mail_server": "Use profile server",
|
||||||
|
"i18n:govoplan-campaign.unavailable_selected_server": "Selected server is unavailable",
|
||||||
|
"i18n:govoplan-campaign.unavailable_selected_profile": "Selected profile is unavailable",
|
||||||
|
"i18n:govoplan-campaign.discard_superseded": "Discard cancelled because the draft changed while reloading. Your newer changes are still unsaved.",
|
||||||
|
"i18n:govoplan-campaign.inherit_mail_credential_when_allowed": "Use profile credentials (if allowed by policy)",
|
||||||
|
"i18n:govoplan-campaign.unavailable_selected_credential": "Selected credential is unavailable",
|
||||||
|
"i18n:govoplan-campaign.explicit_mail_credential_help": "Credential inheritance is configured under Mail policy → SMTP/IMAP credential inheritance. When it is disabled, choose a credential explicitly here and save. A profile default is not an explicit campaign selection; inherited policy locks remain in force.",
|
||||||
|
"i18n:govoplan-campaign.save_cancelled": "Save cancelled; your changes are still unsaved.",
|
||||||
|
"i18n:govoplan-campaign.saved_newer_changes_pending": "Submitted changes saved; newer changes are still unsaved.",
|
||||||
|
"i18n:govoplan-campaign.saved_refresh_failed": "The campaign was saved, but the follow-up refresh failed. Reload when available; do not repeat the save to recover the refresh.",
|
||||||
|
"i18n:govoplan-campaign.attachment_checks_await_mail_migration": "Current attachment checks are awaiting Mail migration and have not been evaluated. Migrate to an authorized Mail profile, then refresh the attachment preview and validate again.",
|
||||||
|
"i18n:govoplan-campaign.legacy_mail_migration_required": "This older version still contains campaign-local mail settings. Content and archive corrections can be saved while the selected Mail references remain unchanged; legacy transport stays stored and blocked from delivery. Before validating, building, or sending, open Mail settings, select an authorized Mail profile, and explicitly save the migration. Unchanged ZIP settings do not block this migration. Existing review records remain available; credentials never reach the browser. Locked versions must first be unlocked or copied using the available version action.",
|
||||||
|
"i18n:govoplan-campaign.open_mail_settings": "Open Mail settings",
|
||||||
|
"i18n:govoplan-campaign.migrate_selected_mail_profile": "Migrate to selected Mail profile",
|
||||||
|
"i18n:govoplan-campaign.migrating_mail_profile": "Migrating Mail settings…",
|
||||||
"i18n:govoplan-campaign.collaboration": "Collaboration",
|
"i18n:govoplan-campaign.collaboration": "Collaboration",
|
||||||
"i18n:govoplan-campaign.collaboration_description": "Bounded human discussion linked to stable Campaign evidence.",
|
"i18n:govoplan-campaign.collaboration_description": "Bounded human discussion linked to stable Campaign evidence.",
|
||||||
"i18n:govoplan-campaign.loading_collaboration": "Loading campaign collaboration…",
|
"i18n:govoplan-campaign.loading_collaboration": "Loading campaign collaboration…",
|
||||||
@@ -66,7 +236,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Where to go",
|
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Where to go",
|
||||||
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Critical blockers",
|
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Critical blockers",
|
||||||
"i18n:govoplan-campaign.remaining.cc632b5e": "Remaining",
|
"i18n:govoplan-campaign.remaining.cc632b5e": "Remaining",
|
||||||
"i18n:govoplan-campaign.individual_review.402783bf": "Individual review",
|
"i18n:govoplan-campaign.individual_review.402783bf": "Message decisions",
|
||||||
"i18n:govoplan-campaign.group_review.a809a9d9": "Group review",
|
"i18n:govoplan-campaign.group_review.a809a9d9": "Group review",
|
||||||
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blocking validation issue(s) prevent building.",
|
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blocking validation issue(s) prevent building.",
|
||||||
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Correct the affected campaign data, then validate again.",
|
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Correct the affected campaign data, then validate again.",
|
||||||
@@ -81,11 +251,11 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Correct the affected recipient, template, mail, or attachment data and rebuild.",
|
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Correct the affected recipient, template, mail, or attachment data and rebuild.",
|
||||||
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Sender & Recipients, Template, Mail settings, or Files",
|
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Sender & Recipients, Template, Mail settings, or Files",
|
||||||
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} review decision(s) remain ({value1} individual, {value2} group).",
|
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} review decision(s) remain ({value1} individual, {value2} group).",
|
||||||
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Open every critical message and record a decision; then explicitly accept any non-critical group.",
|
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Review the affected messages individually, or select matching conditions with ‘Accept similar review conditions’. Then complete the review to acknowledge any remaining non-critical warnings.",
|
||||||
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Review & Send - Built messages",
|
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Review & Send - Built messages",
|
||||||
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} built message warning(s) need acknowledgement.",
|
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} built message warning(s) need acknowledgement.",
|
||||||
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Must be corrected before delivery.",
|
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Must be corrected before delivery.",
|
||||||
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Requires an individual decision.",
|
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "These messages still need a recorded decision.",
|
||||||
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "May be accepted together after critical review is complete.",
|
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "May be accepted together after critical review is complete.",
|
||||||
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "Already acknowledged in the completed review.",
|
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "Already acknowledged in the completed review.",
|
||||||
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "All required review decisions are complete.",
|
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "All required review decisions are complete.",
|
||||||
@@ -172,7 +342,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Keep delivery work",
|
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Keep delivery work",
|
||||||
"i18n:govoplan-campaign.limit_source.bd933adb": "Limit source",
|
"i18n:govoplan-campaign.limit_source.bd933adb": "Limit source",
|
||||||
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Loading the effective delivery policy…",
|
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Loading the effective delivery policy…",
|
||||||
"i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f": "No real delivery mode is currently available ({value0}).",
|
"i18n:govoplan-campaign.value0_background_delivery_is_not_enabled_on_thi.b53162c5": "{value0} Background delivery is not enabled on this instance. An administrator can adjust the ‘Send now’ limit under Administration → SYSTEM → Campaign delivery (within the deployment ceiling), or configure background workers for longer-running campaigns.",
|
||||||
"i18n:govoplan-campaign.not_available.d1a17af1": "Not available",
|
"i18n:govoplan-campaign.not_available.d1a17af1": "Not available",
|
||||||
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Pause queued work",
|
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Pause queued work",
|
||||||
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "Paused {value0} queued message(s).",
|
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "Paused {value0} queued message(s).",
|
||||||
@@ -192,11 +362,10 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "Send now is available for {value0} eligible message(s), within the effective limit of {value1}. {value2}",
|
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "Send now is available for {value0} eligible message(s), within the effective limit of {value1}. {value2}",
|
||||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "Send now is unavailable: {value0}.",
|
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "Send now is unavailable: {value0}.",
|
||||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "Send now is unavailable ({value0}). Queue for workers remains available.",
|
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "Send now is unavailable ({value0}). Queue for workers remains available.",
|
||||||
"i18n:govoplan-campaign.synchronous_limit.f88c0bcd": "Synchronous limit",
|
"i18n:govoplan-campaign.send_now_message_limit.7521d73d": "‘Send now’ message limit",
|
||||||
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "the built run has no eligible message",
|
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "the built run has no eligible message",
|
||||||
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "the configured maximum",
|
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "the configured maximum",
|
||||||
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "the delivery policy configuration is invalid",
|
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "the delivery policy configuration is invalid",
|
||||||
"i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a": "the exact built run exceeds the effective limit for recipient jobs",
|
|
||||||
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "The worker queue is also available.",
|
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "The worker queue is also available.",
|
||||||
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "This cancels queued or paused messages that have not crossed the SMTP boundary. Accepted, active, or outcome-unknown messages remain protected for audit and reconciliation.",
|
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "This cancels queued or paused messages that have not crossed the SMTP boundary. Accepted, active, or outcome-unknown messages remain protected for audit and reconciliation.",
|
||||||
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "This commits {value0} eligible message(s) from version {value1} to durable worker jobs. You may leave this page and return to the same progress and recovery state.",
|
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "This commits {value0} eligible message(s) from version {value1} to durable worker jobs. You may leave this page and return to the same progress and recovery state.",
|
||||||
@@ -1388,10 +1557,186 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.zip_archive_for_value.3dfaf812": "ZIP archive for {value0}",
|
"i18n:govoplan-campaign.zip_archive_for_value.3dfaf812": "ZIP archive for {value0}",
|
||||||
"i18n:govoplan-campaign.zip_archive.5a2430dd": "ZIP archive",
|
"i18n:govoplan-campaign.zip_archive.5a2430dd": "ZIP archive",
|
||||||
"i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d": "ZIP attachments are disabled.",
|
"i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d": "ZIP attachments are disabled.",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_enable_guidance": "To permit Legacy ZipCrypto, a policy administrator must enable it under Administration → SYSTEM → Campaign archive encryption and save. Tenant and owner policies may still restrict it. Then select Legacy ZipCrypto under Attachments → ZIP attachments and record the weak-encryption acknowledgement and an operational reason of at least 10 characters. No policy or campaign change sends mail.",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_permission_missing": "Your account also needs the dedicated campaigns:archive:use_legacy_zipcrypto permission. Ask an administrator to grant it if this compatibility exception is authorized.",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_module_missing": "The Policy module is not available. AES remains the safe default; Legacy ZipCrypto cannot be enabled here.",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_open_system": "Open system archive policy",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_open_tenant": "Open tenant archive policy",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_reload": "Reload archive policy",
|
||||||
"i18n:govoplan-campaign.zip_attachments.6b58ed68": "ZIP attachments",
|
"i18n:govoplan-campaign.zip_attachments.6b58ed68": "ZIP attachments",
|
||||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||||
},
|
},
|
||||||
"de": {
|
"de": {
|
||||||
|
"i18n:govoplan-campaign.report_claim_still_active": "Die Verarbeitung ist noch aktiv oder ihre Reservierung noch nicht abgelaufen. Eine Wiederherstellung ist nicht verfügbar; warten Sie und laden Sie den gespeicherten Status neu.",
|
||||||
|
"i18n:govoplan-campaign.report_claim_owner_unconfirmed": "Der Server kann noch nicht bestätigen, dass der vorherige Prozess beendet wurde. Zum Schutz vor doppeltem Versand bleibt die Wiederherstellung gesperrt; lassen Sie Prozess und Reservierung administrativ prüfen.",
|
||||||
|
"i18n:govoplan-campaign.report_recipients_to": "An",
|
||||||
|
"i18n:govoplan-campaign.report_recipients_cc": "Cc",
|
||||||
|
"i18n:govoplan-campaign.report_recipients_bcc": "Bcc",
|
||||||
|
"i18n:govoplan-campaign.report_retry_now": "Jetzt erneut versuchen",
|
||||||
|
"i18n:govoplan-campaign.report_send_unattempted_now": "Noch nicht versuchte Nachricht jetzt senden",
|
||||||
|
"i18n:govoplan-campaign.report_retry_page_now": "Fehlgeschlagene Nachrichten dieser Seite jetzt erneut versuchen ({value0})",
|
||||||
|
"i18n:govoplan-campaign.report_send_page_now": "Noch nicht versuchte Nachrichten dieser Seite jetzt senden ({value0})",
|
||||||
|
"i18n:govoplan-campaign.report_queue_retry_workers": "Vorübergehende Fehler für Hintergrundverarbeitung einreihen",
|
||||||
|
"i18n:govoplan-campaign.report_queue_unattempted_workers": "Noch nicht versuchte Nachrichten für Hintergrundverarbeitung einreihen",
|
||||||
|
"i18n:govoplan-campaign.report_workers_unavailable": "Hintergrundverarbeitung ist deaktiviert. Verwenden Sie die ausdrücklichen „jetzt“-Aktionen zur synchronen Fortsetzung.",
|
||||||
|
"i18n:govoplan-campaign.report_inline_scope_help": "„Jetzt“-Aktionen betreffen nur die angegebenen Aufträge der aktuell gefilterten Seite und beachten die konfigurierte synchrone Grenze. Bereits angenommene oder ungewisse Zustellungen werden niemals automatisch erneut gesendet.",
|
||||||
|
"i18n:govoplan-campaign.report_inline_result": "Versandanfrage abgeschlossen: {value0} versucht, {value1} angenommen, {value2} fehlgeschlagen, {value3} ungewiss, {value4} verbleibend. Prüfen Sie vor dem Fortsetzen die aktualisierten Versandzustände.",
|
||||||
|
"i18n:govoplan-campaign.report_acknowledged_refresh_failed": "Die Aktion wurde bestätigt, aber die Anzeige konnte nicht aktualisiert werden. Laden Sie den Bericht neu; wiederholen Sie die Aktion nicht ungeprüft.",
|
||||||
|
"i18n:govoplan-campaign.report_reconcile_title": "Nachgewiesenes Versandergebnis erfassen",
|
||||||
|
"i18n:govoplan-campaign.report_recover_claim_title": "Unterbrochene Verarbeitung auflösen",
|
||||||
|
"i18n:govoplan-campaign.report_recover_claim_confirm": "Ergebnis zur Klärung markieren",
|
||||||
|
"i18n:govoplan-campaign.report_recover_claim_help": "Der Server hat einen beendeten Verarbeitungsprozess und eine abgelaufene Reservierung festgestellt. Dokumentieren Sie den Nachweis, um diesen Versuch als ungewiss zu markieren. Dadurch wird nichts gesendet und kein Versanderfolg angenommen; klären Sie diesen gesondert, bevor Sie ein Ergebnis erfassen.",
|
||||||
|
"i18n:govoplan-campaign.report_evidence_note": "Nachweis / Begründung",
|
||||||
|
"i18n:govoplan-campaign.report_recording_evidence": "Nachweis wird gespeichert…",
|
||||||
|
"i18n:govoplan-campaign.report_record_accepted": "SMTP-Annahme bestätigen",
|
||||||
|
"i18n:govoplan-campaign.report_record_not_sent": "Nachricht als nicht gesendet erfassen",
|
||||||
|
"i18n:govoplan-campaign.report_accepted_evidence_help": "Bestätigen Sie erst nach Prüfung der Annahme oder der gespeicherten IMAP-Kopie anhand der Mailserver-Protokolle oder des Postfachs. Beschreiben Sie den Nachweis. Das Erfassen sendet die Nachricht nicht erneut.",
|
||||||
|
"i18n:govoplan-campaign.report_not_sent_evidence_help": "Bestätigen Sie nur mit einem Nachweis, dass die Nachricht nicht angenommen oder die IMAP-Kopie nicht gespeichert wurde. Eine falsche Entscheidung kann ein Duplikat ermöglichen. Ein erneuter Versuch bleibt eine gesonderte ausdrückliche Aktion.",
|
||||||
|
"i18n:govoplan-campaign.report_claim_recovered": "Die unterbrochene Verarbeitung ist jetzt als ungewiss markiert. Prüfen Sie die Nachweise des Anbieters und erfassen Sie das bestätigte Ergebnis gesondert.",
|
||||||
|
"i18n:govoplan-campaign.report_evidence_recorded": "Das nachgewiesene Ergebnis wurde erfasst. Ein erneuter Versuch bleibt eine gesonderte ausdrückliche Aktion.",
|
||||||
|
"i18n:govoplan-campaign.report_recover_smtp_claim": "Unterbrochene SMTP-Verarbeitung auflösen",
|
||||||
|
"i18n:govoplan-campaign.report_recover_imap_claim": "Unterbrochene IMAP-Verarbeitung auflösen",
|
||||||
|
"i18n:govoplan-campaign.report_record_imap_appended": "IMAP-Kopie als gespeichert erfassen",
|
||||||
|
"i18n:govoplan-campaign.report_record_imap_not_appended": "IMAP-Kopie als nicht gespeichert erfassen",
|
||||||
|
"i18n:govoplan-campaign.delivery_policy_admin": "Campaign-Versand",
|
||||||
|
"i18n:govoplan-campaign.configure_sending_limit": "Versandlimit konfigurieren",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_smtp": "Nachrichten werden gesendet",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_not_queued": "Nicht eingereiht",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_skipped": "Ausgeschlossen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_queued": "Eingereiht",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_claimed": "Übernommen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_sending": "Wird gesendet",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_smtp_accepted": "Von SMTP angenommen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_postbox_accepted": "Vom Postfach angenommen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_print_accepted": "Vom Druck angenommen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_delivered": "Zugestellt",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_partially_accepted": "Teilweise angenommen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_sent": "Gesendet",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_outcome_unknown": "Ergebnis ungewiss",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_failed_temporary": "Vorübergehend fehlgeschlagen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_failed_permanent": "Dauerhaft fehlgeschlagen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_cancelled": "Abgebrochen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_not_requested": "Nicht angefordert",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_pending": "Ausstehend",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_appending": "Wird nach Gesendet kopiert",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_appended": "Nach Gesendet kopiert",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_failed": "Fehlgeschlagen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_ready": "Bereit",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_accepted": "Angenommen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_delivering": "Wird zugestellt",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_accepted_vacant": "Angenommen (unbesetzt)",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_rejected_temporary": "Vorübergehend abgewiesen",
|
||||||
|
"i18n:govoplan-campaign.delivery_status_rejected_permanent": "Dauerhaft abgewiesen",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_imap": "Nachrichten werden nach Gesendet kopiert",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_processed": "{value0}/{value1} verarbeitet",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_loading": "Gespeicherten Fortschritt laden…",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_running": "Die Verarbeitung läuft. Nur diese gespeicherten Zähler werden aktualisiert; die Kampagnenseite bleibt unverändert.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_finished": "Die Anfrage ist abgeschlossen. Prüfen Sie unten, ob Arbeit offen ist oder Probleme aufgetreten sind.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_interrupted": "Die Verbindung wurde beendet; die Verarbeitung kann noch laufen. Die gespeicherten Zähler werden weiter aktualisiert. Senden oder kopieren Sie nicht erneut, bevor das Ergebnis geklärt ist.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_stale": "Der Fortschritt konnte nicht aktualisiert werden. Die letzten Zähler bleiben erhalten; die Abfrage wird wiederholt. Das bedeutet nicht, dass die Verarbeitung fehlgeschlagen ist.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_accepted": "Von SMTP angenommen",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_appended": "Nach Gesendet kopiert",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_active": "In Bearbeitung",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_pending": "Ausstehend",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_failed": "Fehlgeschlagen",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_unknown": "Ergebnis ungewiss",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_excluded": "Ausgeschlossen",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_paused": "Pausiert",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_cancelled": "Abgebrochen",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_updated": "Zuletzt geprüft",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_scope": "Die Zähler gelten für diese Kampagnenversion. Verarbeitet umfasst erfolgreiche, fehlgeschlagene, ungewisse und abgebrochene Ergebnisse; ausgeschlossene Nachrichten gehören nicht zur Gesamtzahl. Laufende Nachrichten werden getrennt gezählt.",
|
||||||
|
"i18n:govoplan-campaign.delivery_progress_refresh_failed": "Versanddetails konnten nicht aktualisiert werden. Gespeicherte Ergebnisse bleiben erhalten; dies bedeutet keine fehlgeschlagene Verarbeitung. Aktualisieren Sie die Anzeige mit Neu laden, statt die Aktion zu wiederholen.",
|
||||||
|
"i18n:govoplan-campaign.delivery_smtp_finished_summary": "Versand abgeschlossen. Von SMTP angenommen: {value0} Nachrichten, fehlgeschlagen: {value1}, Ergebnis ungewiss: {value2}, pausiert: {value3}.",
|
||||||
|
"i18n:govoplan-campaign.delivery_imap_finished_summary": "IMAP-Kopie hat {value0} Aufträge verarbeitet: kopiert {value1}, fehlgeschlagen {value2}.",
|
||||||
|
"i18n:govoplan-campaign.delivery_imap_queued_summary": "{value0} ausstehende IMAP-Kopien eingereiht.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_before_lock": "Verknüpfen Sie alle benötigten Dateien, bevor Sie die Kampagne sperren. Nach dem Sperren kann die Dateiauswahl nicht mehr geändert werden.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_locked_warning": "Diese Version ist gesperrt: Dateien können nicht verknüpft werden. Nicht verknüpfte Dateien gehören nicht zum eingefrorenen Anhangsbestand. Verknüpfen Sie die Dateien in einer bearbeitbaren Version und validieren, bauen und prüfen Sie diese vor dem Senden.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_loading_reason": "Dateitreffer werden noch geladen. Warten Sie vor dem Verknüpfen oder Sperren.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_failed_reason": "Dateitreffer konnten nicht geprüft werden. Aktualisieren Sie die Anhangsvorschau vor dem Sperren.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_lock_failed": "Das Sperren wurde gestoppt, weil die aktuelle Dateiverknüpfungsvorschau nicht geprüft werden konnte. Aktualisieren Sie diese und versuchen Sie es erneut.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_readonly_reason": "Diese Version ist schreibgeschützt; verknüpfen Sie Dateien in einer bearbeitbaren Version.",
|
||||||
|
"i18n:govoplan-campaign.attachment_link_all_done": "Alle gefundenen Dateien sind bereits verknüpft.",
|
||||||
|
"i18n:govoplan-campaign.system_delivery_policy_source": "Systemweite Versandeinstellungen",
|
||||||
|
"i18n:govoplan-campaign.message_state_filter_help": "Der Status berücksichtigt gespeicherte Entscheidungen für diesen Nachrichtenstand: Bereit, Prüfung erforderlich, Blockiert oder Ausgeschlossen. Die Schaltfläche für Prüfkandidaten setzt denselben Statusfilter; die Erklärungsspalte zeigt den jeweiligen Grund.",
|
||||||
|
"i18n:govoplan-campaign.validation_grouping_help": "Zusammengehörige Ursachen aus Anhangsregeln und ihre Richtlinienfolge werden gemeinsam angezeigt. Die technischen Details enthalten jeden ursprünglichen Prüfeintrag; die Seitenzählung bezieht sich auf zusammengefasste Bedingungen.",
|
||||||
|
"i18n:govoplan-campaign.validation_policy_outcome": "Richtlinienfolge:",
|
||||||
|
"i18n:govoplan-campaign.validation_technical_evidence": "Technische Details",
|
||||||
|
"i18n:govoplan-campaign.repeated_file_uses": "{value0} Verwendungen",
|
||||||
|
"i18n:govoplan-campaign.message_state_heading": "Nachrichtenstatus",
|
||||||
|
"i18n:govoplan-campaign.message_state_ready": "Bereit",
|
||||||
|
"i18n:govoplan-campaign.message_state_needs_review": "Prüfung erforderlich",
|
||||||
|
"i18n:govoplan-campaign.message_state_blocked_label": "Blockiert",
|
||||||
|
"i18n:govoplan-campaign.message_state_excluded_label": "Ausgeschlossen",
|
||||||
|
"i18n:govoplan-campaign.message_state_explanation_heading": "Begründung",
|
||||||
|
"i18n:govoplan-campaign.message_state_automatic": "Build erfolgreich; keine manuelle Entscheidung erforderlich.",
|
||||||
|
"i18n:govoplan-campaign.message_state_individual_pending": "Der Build erfordert eine ausdrückliche Prüfentscheidung für diese Nachricht.",
|
||||||
|
"i18n:govoplan-campaign.message_state_individually_accepted": "Build-Ausnahme mit gespeicherter Prüfentscheidung bestätigt.",
|
||||||
|
"i18n:govoplan-campaign.message_state_warning_pending": "Nicht blockierende Build-Warnung; mit Prüfung abschließen bestätigen.",
|
||||||
|
"i18n:govoplan-campaign.message_state_warning_accepted": "Build-Warnung beim Abschluss der Prüfung bestätigt.",
|
||||||
|
"i18n:govoplan-campaign.message_state_excluded": "Durch konfigurierte Richtlinie ausgeschlossen; diese Nachricht wird nicht gesendet.",
|
||||||
|
"i18n:govoplan-campaign.message_state_inactive": "Der Empfänger ist inaktiv; diese Nachricht wird nicht gesendet.",
|
||||||
|
"i18n:govoplan-campaign.message_state_build_failed": "Die Nachricht konnte nicht gebaut werden. Quelle korrigieren und erneut bauen.",
|
||||||
|
"i18n:govoplan-campaign.message_state_not_built": "Es liegt kein abgeschlossener Nachrichten-Build vor.",
|
||||||
|
"i18n:govoplan-campaign.message_state_blocked": "Eine blockierende Bedingung muss korrigiert und neu gebaut werden; sie kann nicht bestätigt werden.",
|
||||||
|
"i18n:govoplan-campaign.message_state_unknown": "Der Build-Status ist nicht verfügbar. Vor dem Fortfahren neu laden oder erneut bauen.",
|
||||||
|
"i18n:govoplan-campaign.synchronous_limit_explanation": "Diese Kampagne hat {value0} versandfähige Nachrichten; „Jetzt senden“ erlaubt höchstens {value1} pro Anfrage. Größere Kampagnen verwenden den Hintergrundversand.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_build_first": "Erstellen Sie die Nachrichten, um ihre genauen Anhangsanforderungen zu prüfen.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_loading": "Prüfnachweise für den aktuellen Nachrichtenstand werden geladen.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_blocked": "Der aktuelle Nachrichtenstand enthält noch Blocker. Beheben Sie diese vor dem Versand; Prüfentscheidungen können Blocker nicht übergehen.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_satisfied": "Die Anhangsbedingungen sind erfüllt oder für diesen Nachrichtenstand bereits bestätigt. Eine weitere Anhangsfreigabe ist nicht erforderlich.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_review": "Anhangsausnahmen benötigen noch eine Entscheidung unter „Erstellen und prüfen“. Gespeicherte Entscheidungen gelten hier automatisch weiter.",
|
||||||
|
"i18n:govoplan-campaign.attachment_preflight_pending": "Es werden keine fehlenden oder mehrdeutigen Treffer gemeldet. Schließen Sie vor dem Versand die verbleibende Nachrichtenprüfung ab.",
|
||||||
|
"i18n:govoplan-campaign.review_save_pending": "Eine Prüfentscheidung wird gespeichert. Bitte warten Sie.",
|
||||||
|
"i18n:govoplan-campaign.review_build_changed": "Die erstellten Nachrichten wurden geändert. Laden Sie die Prüfung vor einer weiteren Entscheidung neu; bisherige Freigaben gelten nicht für neue Nachrichten.",
|
||||||
|
"i18n:govoplan-campaign.review_decision_saved": "Prüfentscheidung gespeichert. Sie können die Prüfung später fortsetzen.",
|
||||||
|
"i18n:govoplan-campaign.review_reason_required": "Begründen Sie, warum ohne diese Anlagen fortgefahren werden darf.",
|
||||||
|
"i18n:govoplan-campaign.review_reason_label": "Warum ist das in Ordnung?",
|
||||||
|
"i18n:govoplan-campaign.review_note_label": "Prüfvermerk (optional)",
|
||||||
|
"i18n:govoplan-campaign.review_reason_help": "Wird beim Akzeptieren mit dieser Nachricht gespeichert. Danach öffnet sich automatisch die nächste Nachricht; der Fortschritt bleibt auch vor Abschluss der gesamten Prüfung erhalten.",
|
||||||
|
"i18n:govoplan-campaign.review_saving_decision": "Entscheidung wird gespeichert…",
|
||||||
|
"i18n:govoplan-campaign.review_accept_next": "Akzeptieren und weiter",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_title": "Gleichartige Prüfbedingungen bestätigen",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_missing_required": "Erforderlicher Anhang wurde nicht gefunden",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_missing_optional": "Optionaler Anhang wurde nicht gefunden",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_no_attachments": "Nachricht enthält keine Anhänge",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_multiple_matches": "Mehrere Dateien passen zu einer Einzeldatei-Regel",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_shared_attachment": "Derselbe Anhang wird für mehrere Empfänger verwendet",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_attachment_exception": "Anhangsregel erfordert eine Ausnahme",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_unused_files": "Einigen Anhangsdateien ist kein Empfänger zugeordnet",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_missing_address": "E-Mail-Adresse des Empfängers fehlt",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_technical_codes": "Technische Problemcodes",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_scope": "Wählen Sie eine vom Server geprüfte Problemkategorie unter den aktuell geladenen passenden Nachrichten. Bestätigt werden ausschließlich die hier ausgewählten Empfänger. Blocker, bereits geprüfte Nachrichten, beabsichtigte richtlinienbedingte Ausschlüsse und erlaubte Anhangsregeln ohne Treffer sind nicht enthalten. Dies speichert Prüfnachweise; es sendet keine Nachrichten und schließt die Prüfung nicht ab.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_category": "Kategorie der Prüfbedingungen",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_other_category": "Andere Prüfbedingungen",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_empty": "Keine passenden Nachrichten sind für eine gemeinsame Prüfentscheidung geeignet.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_subset": "Angezeigt werden {value0} von {value1} passenden ungeprüften Nachrichten dieser Kategorie; {value2} ausgewählt.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_limit": "Eine Entscheidung umfasst höchstens 200 Nachrichten. Öffnen Sie den Dialog nach dem Speichern dieser Auswahl erneut, um die verbleibenden Nachrichten zu prüfen.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_select_all": "Alle angezeigten auswählen",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_select_none": "Auswahl aufheben",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_recipients": "Nachrichten dieser Entscheidung",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_reason_required": "Gemeinsame Begründung für Anhangsausnahmen (erforderlich)",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_reason": "Gemeinsamer Prüfvermerk",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_reason_help": "Dieser Vermerk gilt für jede ausgewählte Nachricht und wird mit deren eingefrorenem Build-Nachweis gespeichert. Prüfen Sie Empfänger und Bedingungen vor dem Bestätigen.",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_accept_count": "{value0} ausgewählte Nachrichten bestätigen",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_saving": "Prüfentscheidungen werden gespeichert…",
|
||||||
|
"i18n:govoplan-campaign.bulk_review_stale": "Der Build hat sich bei geöffnetem Dialog geändert. Schließen Sie ihn und prüfen Sie den aktuellen Build, bevor Sie Bedingungen bestätigen.",
|
||||||
|
"i18n:govoplan-campaign.file_chooser_unavailable": "Die Dateiauswahl ist derzeit nicht verfügbar. Ihre Anlageneinstellungen sind unverändert. Falls sie nach dem Laden des Moduls nicht wieder erscheint, speichern Sie ausstehende Änderungen und laden Sie die Seite neu.",
|
||||||
|
"i18n:govoplan-campaign.choose_available_attachment_source": "Die Quelle der Anlage ist nicht mehr verfügbar. Wählen Sie in dieser Zeile einen verfügbaren Basispfad, bevor Sie eine Datei oder ein Muster auswählen.",
|
||||||
|
"i18n:govoplan-campaign.inherit_mail_server": "Server aus dem Profil verwenden",
|
||||||
|
"i18n:govoplan-campaign.unavailable_selected_server": "Ausgewählter Server ist nicht verfügbar",
|
||||||
|
"i18n:govoplan-campaign.unavailable_selected_profile": "Ausgewähltes Profil ist nicht verfügbar",
|
||||||
|
"i18n:govoplan-campaign.discard_superseded": "Verwerfen abgebrochen, weil der Entwurf während des Neuladens geändert wurde. Ihre neueren Änderungen sind weiterhin ungespeichert.",
|
||||||
|
"i18n:govoplan-campaign.inherit_mail_credential_when_allowed": "Profil-Zugangsdaten verwenden (wenn die Richtlinie dies erlaubt)",
|
||||||
|
"i18n:govoplan-campaign.unavailable_selected_credential": "Ausgewählte Zugangsdaten sind nicht verfügbar",
|
||||||
|
"i18n:govoplan-campaign.explicit_mail_credential_help": "Die Vererbung der Zugangsdaten wird unter Mail-Richtlinie → SMTP-/IMAP-Zugangsdatenvererbung festgelegt. Ist sie ausgeschaltet, wählen Sie hier ausdrücklich Zugangsdaten aus und speichern Sie. Ein Profilstandard ist keine ausdrückliche Kampagnenauswahl; geerbte Richtliniensperren bleiben wirksam.",
|
||||||
|
"i18n:govoplan-campaign.save_cancelled": "Speichern abgebrochen; Ihre Änderungen sind weiterhin ungespeichert.",
|
||||||
|
"i18n:govoplan-campaign.saved_newer_changes_pending": "Übermittelte Änderungen gespeichert; neuere Änderungen sind weiterhin ungespeichert.",
|
||||||
|
"i18n:govoplan-campaign.saved_refresh_failed": "Die Kampagne wurde gespeichert, aber das anschließende Neuladen ist fehlgeschlagen. Laden Sie erneut, sobald dies möglich ist; wiederholen Sie dafür nicht den Speichervorgang.",
|
||||||
|
"i18n:govoplan-campaign.attachment_checks_await_mail_migration": "Die aktuellen Anhangprüfungen warten auf die Mail-Migration und wurden noch nicht ausgewertet. Stellen Sie auf ein berechtigtes Mail-Profil um, aktualisieren Sie anschließend die Anhangvorschau und validieren Sie erneut.",
|
||||||
|
"i18n:govoplan-campaign.legacy_mail_migration_required": "Diese ältere Version enthält noch kampagneneigene E-Mail-Einstellungen. Inhalts- und Archivkorrekturen können bei unveränderten Mail-Referenzen gespeichert werden; der alte Transport bleibt gespeichert und für den Versand gesperrt. Öffnen Sie vor Validierung, Erstellung oder Versand die Mail-Einstellungen, wählen Sie ein berechtigtes Profil und speichern Sie die Migration ausdrücklich. Unveränderte ZIP-Einstellungen blockieren diese Migration nicht. Prüfprotokolle bleiben einsehbar; Zugangsdaten gelangen nie in den Browser. Gesperrte Versionen müssen zuerst über die verfügbare Versionsaktion entsperrt oder kopiert werden.",
|
||||||
|
"i18n:govoplan-campaign.open_mail_settings": "E-Mail-Einstellungen öffnen",
|
||||||
|
"i18n:govoplan-campaign.migrate_selected_mail_profile": "Auf ausgewähltes Mail-Profil umstellen",
|
||||||
|
"i18n:govoplan-campaign.migrating_mail_profile": "E-Mail-Einstellungen werden umgestellt…",
|
||||||
"i18n:govoplan-campaign.collaboration": "Zusammenarbeit",
|
"i18n:govoplan-campaign.collaboration": "Zusammenarbeit",
|
||||||
"i18n:govoplan-campaign.collaboration_description": "Begrenzte menschliche Diskussion mit Bezug auf stabile Kampagnennachweise.",
|
"i18n:govoplan-campaign.collaboration_description": "Begrenzte menschliche Diskussion mit Bezug auf stabile Kampagnennachweise.",
|
||||||
"i18n:govoplan-campaign.loading_collaboration": "Kampagnenzusammenarbeit wird geladen…",
|
"i18n:govoplan-campaign.loading_collaboration": "Kampagnenzusammenarbeit wird geladen…",
|
||||||
@@ -1456,7 +1801,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Ziel",
|
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Ziel",
|
||||||
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Kritische Blockaden",
|
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Kritische Blockaden",
|
||||||
"i18n:govoplan-campaign.remaining.cc632b5e": "Verbleibend",
|
"i18n:govoplan-campaign.remaining.cc632b5e": "Verbleibend",
|
||||||
"i18n:govoplan-campaign.individual_review.402783bf": "Einzelprüfung",
|
"i18n:govoplan-campaign.individual_review.402783bf": "Nachrichtenentscheidungen",
|
||||||
"i18n:govoplan-campaign.group_review.a809a9d9": "Gruppenprüfung",
|
"i18n:govoplan-campaign.group_review.a809a9d9": "Gruppenprüfung",
|
||||||
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blockierende Validierungsprobleme verhindern die Erstellung.",
|
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blockierende Validierungsprobleme verhindern die Erstellung.",
|
||||||
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Korrigieren Sie die betroffenen Kampagnendaten und validieren Sie erneut.",
|
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Korrigieren Sie die betroffenen Kampagnendaten und validieren Sie erneut.",
|
||||||
@@ -1471,11 +1816,11 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Korrigieren Sie die betroffenen Empfänger-, Vorlagen-, Mail- oder Anhangsdaten und erstellen Sie erneut.",
|
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Korrigieren Sie die betroffenen Empfänger-, Vorlagen-, Mail- oder Anhangsdaten und erstellen Sie erneut.",
|
||||||
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Absender & Empfänger, Vorlage, Mail-Einstellungen oder Dateien",
|
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Absender & Empfänger, Vorlage, Mail-Einstellungen oder Dateien",
|
||||||
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} Prüfentscheidungen verbleiben ({value1} einzeln, {value2} als Gruppe).",
|
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} Prüfentscheidungen verbleiben ({value1} einzeln, {value2} als Gruppe).",
|
||||||
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Öffnen Sie jede kritische Nachricht und dokumentieren Sie eine Entscheidung; akzeptieren Sie anschließend ausdrücklich die nichtkritische Gruppe.",
|
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Prüfen Sie die betroffenen Nachrichten einzeln oder wählen Sie passende Bedingungen unter „Gleichartige Prüfbedingungen bestätigen“. Schließen Sie anschließend die Prüfung ab, um verbleibende nichtkritische Warnungen zu bestätigen.",
|
||||||
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Prüfen & Senden - Erstellte Nachrichten",
|
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Prüfen & Senden - Erstellte Nachrichten",
|
||||||
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} Warnungen zu erstellten Nachrichten müssen bestätigt werden.",
|
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} Warnungen zu erstellten Nachrichten müssen bestätigt werden.",
|
||||||
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Muss vor dem Versand korrigiert werden.",
|
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Muss vor dem Versand korrigiert werden.",
|
||||||
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Erfordert eine Einzelentscheidung.",
|
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Für diese Nachrichten fehlt noch eine dokumentierte Entscheidung.",
|
||||||
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "Kann nach Abschluss der kritischen Prüfung gemeinsam akzeptiert werden.",
|
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "Kann nach Abschluss der kritischen Prüfung gemeinsam akzeptiert werden.",
|
||||||
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "In der abgeschlossenen Prüfung bereits bestätigt.",
|
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "In der abgeschlossenen Prüfung bereits bestätigt.",
|
||||||
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "Alle erforderlichen Prüfentscheidungen sind abgeschlossen.",
|
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "Alle erforderlichen Prüfentscheidungen sind abgeschlossen.",
|
||||||
@@ -1562,7 +1907,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Versandaufträge beibehalten",
|
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Versandaufträge beibehalten",
|
||||||
"i18n:govoplan-campaign.limit_source.bd933adb": "Quelle des Grenzwerts",
|
"i18n:govoplan-campaign.limit_source.bd933adb": "Quelle des Grenzwerts",
|
||||||
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Wirksame Versandrichtlinie wird geladen…",
|
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Wirksame Versandrichtlinie wird geladen…",
|
||||||
"i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f": "Derzeit ist kein echter Versandmodus verfügbar ({value0}).",
|
"i18n:govoplan-campaign.value0_background_delivery_is_not_enabled_on_thi.b53162c5": "{value0} Der Hintergrundversand ist auf dieser Instanz nicht aktiviert. Die Administration kann unter Administration → SYSTEM → Kampagnenversand das Limit für „Jetzt senden“ innerhalb der Deployment-Obergrenze anpassen oder Hintergrund-Worker für länger laufende Kampagnen einrichten.",
|
||||||
"i18n:govoplan-campaign.not_available.d1a17af1": "Nicht verfügbar",
|
"i18n:govoplan-campaign.not_available.d1a17af1": "Nicht verfügbar",
|
||||||
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Wartende Aufträge pausieren",
|
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Wartende Aufträge pausieren",
|
||||||
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "{value0} wartende Nachricht(en) pausiert.",
|
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "{value0} wartende Nachricht(en) pausiert.",
|
||||||
@@ -1582,11 +1927,10 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "„Jetzt senden“ ist für {value0} geeignete Nachricht(en) innerhalb des wirksamen Grenzwerts von {value1} verfügbar. {value2}",
|
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "„Jetzt senden“ ist für {value0} geeignete Nachricht(en) innerhalb des wirksamen Grenzwerts von {value1} verfügbar. {value2}",
|
||||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "„Jetzt senden“ ist nicht verfügbar: {value0}.",
|
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "„Jetzt senden“ ist nicht verfügbar: {value0}.",
|
||||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "„Jetzt senden“ ist nicht verfügbar ({value0}). Die Worker-Warteschlange bleibt verfügbar.",
|
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "„Jetzt senden“ ist nicht verfügbar ({value0}). Die Worker-Warteschlange bleibt verfügbar.",
|
||||||
"i18n:govoplan-campaign.synchronous_limit.f88c0bcd": "Grenzwert für synchronen Versand",
|
"i18n:govoplan-campaign.send_now_message_limit.7521d73d": "Nachrichtenlimit für „Jetzt senden“",
|
||||||
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "der erstellte Lauf enthält keine geeignete Nachricht",
|
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "der erstellte Lauf enthält keine geeignete Nachricht",
|
||||||
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "das konfigurierte Maximum",
|
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "das konfigurierte Maximum",
|
||||||
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "die Konfiguration der Versandrichtlinie ist ungültig",
|
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "die Konfiguration der Versandrichtlinie ist ungültig",
|
||||||
"i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a": "der exakt erstellte Lauf überschreitet den wirksamen Grenzwert für Empfängeraufträge",
|
|
||||||
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "Die Worker-Warteschlange ist ebenfalls verfügbar.",
|
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "Die Worker-Warteschlange ist ebenfalls verfügbar.",
|
||||||
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "Dadurch werden wartende oder pausierte Nachrichten abgebrochen, die die SMTP-Grenze noch nicht überschritten haben. Angenommene, aktive oder ungeklärte Nachrichten bleiben für Audit und Abstimmung geschützt.",
|
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "Dadurch werden wartende oder pausierte Nachrichten abgebrochen, die die SMTP-Grenze noch nicht überschritten haben. Angenommene, aktive oder ungeklärte Nachrichten bleiben für Audit und Abstimmung geschützt.",
|
||||||
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "Dadurch werden {value0} geeignete Nachricht(en) aus Version {value1} als dauerhafte Worker-Aufträge eingereiht. Sie können diese Seite verlassen und später zum selben Fortschritts- und Wiederherstellungszustand zurückkehren.",
|
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "Dadurch werden {value0} geeignete Nachricht(en) aus Version {value1} als dauerhafte Worker-Aufträge eingereiht. Sie können diese Seite verlassen und später zum selben Fortschritts- und Wiederherstellungszustand zurückkehren.",
|
||||||
@@ -1953,7 +2297,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.evidence.7ea014de": "Nachweise",
|
"i18n:govoplan-campaign.evidence.7ea014de": "Nachweise",
|
||||||
"i18n:govoplan-campaign.exact": "exact",
|
"i18n:govoplan-campaign.exact": "exact",
|
||||||
"i18n:govoplan-campaign.exclude_from_zip.a6422da1": "Exclude from ZIP",
|
"i18n:govoplan-campaign.exclude_from_zip.a6422da1": "Exclude from ZIP",
|
||||||
"i18n:govoplan-campaign.excluded.9804952b": "Excluded",
|
"i18n:govoplan-campaign.excluded.9804952b": "Ausgeschlossen",
|
||||||
"i18n:govoplan-campaign.excludes_the_affected_message.73be963f": "Excludes the affected message.",
|
"i18n:govoplan-campaign.excludes_the_affected_message.73be963f": "Excludes the affected message.",
|
||||||
"i18n:govoplan-campaign.execution_snapshot.5a67f098": "Execution snapshot",
|
"i18n:govoplan-campaign.execution_snapshot.5a67f098": "Execution snapshot",
|
||||||
"i18n:govoplan-campaign.exercise_the_delivery_path_and_verify_recipient_.12ed1928": "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers. This dev workflow can be optional before real sending.",
|
"i18n:govoplan-campaign.exercise_the_delivery_path_and_verify_recipient_.12ed1928": "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers. This dev workflow can be optional before real sending.",
|
||||||
@@ -2187,7 +2531,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"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",
|
||||||
"i18n:govoplan-campaign.needs_linking.a0fc8341": "Needs linking",
|
"i18n:govoplan-campaign.needs_linking.a0fc8341": "Needs linking",
|
||||||
"i18n:govoplan-campaign.needs_review.33a506cf": "Needs review",
|
"i18n:govoplan-campaign.needs_review.33a506cf": "Prüfung erforderlich",
|
||||||
"i18n:govoplan-campaign.needs_setup.522ebae4": "Needs setup",
|
"i18n:govoplan-campaign.needs_setup.522ebae4": "Needs setup",
|
||||||
"i18n:govoplan-campaign.new_attachment_source.563145ba": "New attachment source",
|
"i18n:govoplan-campaign.new_attachment_source.563145ba": "New attachment source",
|
||||||
"i18n:govoplan-campaign.new_campaign.1f0d021c": "New Campaign",
|
"i18n:govoplan-campaign.new_campaign.1f0d021c": "New Campaign",
|
||||||
@@ -2537,12 +2881,12 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
"i18n:govoplan-campaign.shared.50d0d8dd": "Shared",
|
||||||
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
"i18n:govoplan-campaign.sheet.53bc47a7": "Sheet",
|
||||||
"i18n:govoplan-campaign.short_reminder_template_with_one_deadline_field.5f15d110": "Short reminder template with one deadline field.",
|
"i18n:govoplan-campaign.short_reminder_template_with_one_deadline_field.5f15d110": "Short reminder template with one deadline field.",
|
||||||
"i18n:govoplan-campaign.show_all_messages.1c2107a1": "Show all messages",
|
"i18n:govoplan-campaign.show_all_messages.1c2107a1": "Alle Nachrichten anzeigen",
|
||||||
"i18n:govoplan-campaign.show_captured_mock_mailbox.019e1e79": "Show captured mock mailbox",
|
"i18n:govoplan-campaign.show_captured_mock_mailbox.019e1e79": "Show captured mock mailbox",
|
||||||
"i18n:govoplan-campaign.show_content.0528d8d2": "Inhalt anzeigen",
|
"i18n:govoplan-campaign.show_content.0528d8d2": "Inhalt anzeigen",
|
||||||
"i18n:govoplan-campaign.show_guided_warnings_while_editing.bc5dba85": "Show guided warnings while editing",
|
"i18n:govoplan-campaign.show_guided_warnings_while_editing.bc5dba85": "Show guided warnings while editing",
|
||||||
"i18n:govoplan-campaign.show_header_only.24afefca": "Nur Überschrift anzeigen",
|
"i18n:govoplan-campaign.show_header_only.24afefca": "Nur Überschrift anzeigen",
|
||||||
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Show review candidates only",
|
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Nur Prüfkandidaten anzeigen",
|
||||||
"i18n:govoplan-campaign.showing.163d8174": "Showing",
|
"i18n:govoplan-campaign.showing.163d8174": "Showing",
|
||||||
"i18n:govoplan-campaign.size.b7152342": "Größe",
|
"i18n:govoplan-campaign.size.b7152342": "Größe",
|
||||||
"i18n:govoplan-campaign.skipped.5a000ad7": "Übersprungen",
|
"i18n:govoplan-campaign.skipped.5a000ad7": "Übersprungen",
|
||||||
@@ -2778,6 +3122,12 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.zip_archive_for_value.3dfaf812": "ZIP archive for {value0}",
|
"i18n:govoplan-campaign.zip_archive_for_value.3dfaf812": "ZIP archive for {value0}",
|
||||||
"i18n:govoplan-campaign.zip_archive.5a2430dd": "ZIP archive",
|
"i18n:govoplan-campaign.zip_archive.5a2430dd": "ZIP archive",
|
||||||
"i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d": "ZIP attachments are disabled.",
|
"i18n:govoplan-campaign.zip_attachments_are_disabled.6969b41d": "ZIP attachments are disabled.",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_enable_guidance": "Um Legacy ZipCrypto zuzulassen, muss die Richtlinienadministration es unter Administration → SYSTEM → Campaign archive encryption aktivieren und speichern. Mandanten- und Eigentümerrichtlinien können es weiterhin einschränken. Wählen Sie anschließend Legacy ZipCrypto unter Anhänge → ZIP-Anhänge und bestätigen Sie die schwache Verschlüsselung mit einer betrieblichen Begründung von mindestens 10 Zeichen. Richtlinien- und Kampagnenänderungen versenden keine E-Mails.",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_permission_missing": "Ihr Konto benötigt außerdem die gesonderte Berechtigung campaigns:archive:use_legacy_zipcrypto. Bitten Sie die Administration um die Vergabe, wenn diese Kompatibilitätsausnahme genehmigt ist.",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_module_missing": "Das Policy-Modul ist nicht verfügbar. AES bleibt der sichere Standard; Legacy ZipCrypto kann hier nicht aktiviert werden.",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_open_system": "System-Archivrichtlinie öffnen",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_open_tenant": "Mandanten-Archivrichtlinie öffnen",
|
||||||
|
"i18n:govoplan-campaign.archive_policy_reload": "Archivrichtlinie neu laden",
|
||||||
"i18n:govoplan-campaign.zip_attachments.6b58ed68": "ZIP attachments",
|
"i18n:govoplan-campaign.zip_attachments.6b58ed68": "ZIP attachments",
|
||||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Navigate, useParams } from "react-router";
|
|||||||
import {
|
import {
|
||||||
ResourceAccessBoundary,
|
ResourceAccessBoundary,
|
||||||
hasAnyScope,
|
hasAnyScope,
|
||||||
|
hasScope,
|
||||||
|
type AdminSectionsUiCapability,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type DashboardWidgetsUiCapability,
|
type DashboardWidgetsUiCapability,
|
||||||
@@ -19,6 +21,18 @@ import "./styles/campaign-workspace.css";
|
|||||||
|
|
||||||
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
|
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
|
||||||
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
||||||
|
const CampaignDeliveryPolicyPanel = lazy(() => import("./features/admin/CampaignDeliveryPolicyPanel"));
|
||||||
|
const campaignAdminSections: AdminSectionsUiCapability = {
|
||||||
|
sections: (["system", "tenant"] as const).map((scope) => ({
|
||||||
|
id: `${scope}-campaign-delivery`, moduleId: "campaigns", kind: "settings" as const,
|
||||||
|
surfaceId: `campaigns.admin.${scope}-delivery`, label: "i18n:govoplan-campaign.delivery_policy_admin", group: scope === "system" ? "SYSTEM" : "TENANT", order: 76,
|
||||||
|
allOf: [scope === "system" ? "system:settings:read" : "admin:policies:read"],
|
||||||
|
render: ({ settings, auth }) => createElement(CampaignDeliveryPolicyPanel, {
|
||||||
|
key: `${scope}:${(auth.active_tenant ?? auth.tenant).id}`, settings, scope,
|
||||||
|
canWrite: hasScope(auth, scope === "system" ? "system:settings:write" : "admin:policies:write")
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
const campaignRead = ["campaigns:campaign:read"];
|
const campaignRead = ["campaigns:campaign:read"];
|
||||||
const reportRead = ["campaigns:report:read"];
|
const reportRead = ["campaigns:report:read"];
|
||||||
@@ -91,6 +105,8 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
optionalDependencies: ["files", "mail", "notifications", "organizations", "idm", "tasks"],
|
optionalDependencies: ["files", "mail", "notifications", "organizations", "idm", "tasks"],
|
||||||
translations,
|
translations,
|
||||||
viewSurfaces: [
|
viewSurfaces: [
|
||||||
|
{ id: "campaigns.admin.system-delivery", moduleId: "campaigns", kind: "section", label: "System Campaign delivery", order: 76 },
|
||||||
|
{ id: "campaigns.admin.tenant-delivery", moduleId: "campaigns", kind: "section", label: "Tenant Campaign delivery", order: 76 },
|
||||||
{
|
{
|
||||||
id: "campaigns.page.work",
|
id: "campaigns.page.work",
|
||||||
moduleId: "campaigns",
|
moduleId: "campaigns",
|
||||||
@@ -123,6 +139,7 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
{ path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) },
|
{ path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) },
|
||||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
|
"admin.sections": campaignAdminSections,
|
||||||
"dashboard.widgets": campaignDashboardWidgets,
|
"dashboard.widgets": campaignDashboardWidgets,
|
||||||
"quickAccess.tools": campaignQuickAccessTools,
|
"quickAccess.tools": campaignQuickAccessTools,
|
||||||
"wizard.directories": campaignWizardDirectories
|
"wizard.directories": campaignWizardDirectories
|
||||||
|
|||||||
@@ -776,9 +776,6 @@
|
|||||||
.attachment-rules-table td:nth-child(5) { width: 160px; }
|
.attachment-rules-table td:nth-child(5) { width: 160px; }
|
||||||
.attachment-rules-table th:last-child,
|
.attachment-rules-table th:last-child,
|
||||||
.attachment-rules-table td:last-child { width: 150px; }
|
.attachment-rules-table td:last-child { width: 150px; }
|
||||||
.attachment-rules-table .data-grid-body-cell:last-child .btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Attachment file chooser panel embedded in the shared rules editor. */
|
/* Attachment file chooser panel embedded in the shared rules editor. */
|
||||||
.attachment-rules-editor {
|
.attachment-rules-editor {
|
||||||
@@ -2244,6 +2241,20 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.campaign-report-recipient-cell,
|
||||||
|
.campaign-report-recipient-cell > div {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipient-outcome-cell.campaign-report-recipient-cell span {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.recipient-outcome-cell strong,
|
.recipient-outcome-cell strong,
|
||||||
.recipient-outcome-cell span {
|
.recipient-outcome-cell span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user