Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:26 +02:00
parent 1b32427813
commit c51fc180fb
111 changed files with 6905 additions and 980 deletions
@@ -192,6 +192,16 @@ def stamp_legacy_zipcrypto_acknowledgements(
if candidate_raw_json is None:
return None, []
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 = {
str(item.get("id") or index): item
for index, item in enumerate(_archive_configs(current_raw_json))
@@ -243,6 +253,9 @@ def stamp_legacy_zipcrypto_acknowledgements(
"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
@@ -233,15 +233,20 @@ def _missing_policy_decision(
candidates,
key=lambda behavior: _MISSING_BEHAVIOR_STRENGTH[behavior],
)
legacy_drop_normalized = configured == Behavior.DROP
if legacy_drop_normalized:
configured = Behavior.BLOCK if config.required else Behavior.ASK
if Behavior.BLOCK in candidates:
configured = Behavior.BLOCK
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(
requirement_policy=requirement_policy,
campaign_policy=campaign_config.attachments.missing_behavior,
rule_policy=config.missing_behavior,
effective_behavior=configured,
legacy_drop_normalized=legacy_drop_normalized,
legacy_drop_normalized=False,
)
@@ -410,7 +415,10 @@ def _issue_for_missing(
) -> AttachmentIssue:
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
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(
severity=severity,
code=code,
@@ -434,9 +442,9 @@ def effective_send_without_attachments_behavior(config: CampaignConfig) -> Behav
configured = config.attachments.send_without_attachments_behavior or (
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
)
# Recipient exclusion must be an explicit reviewed action, not an implicit
# consequence of a legacy attachment policy value.
return Behavior.ASK if configured == Behavior.DROP else configured
# Configured exclusion is already an explicit policy decision. It must not
# be converted into an acceptance prompt that would send the excluded mail.
return configured
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.",
}
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",
message=messages.get(behavior, "No attachment file was resolved for this message."),
behavior=behavior,
@@ -1,6 +1,7 @@
from __future__ import annotations
import copy
import hashlib
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]:
if not isinstance(value, dict) or any(
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
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]:
if not isinstance(value, dict) or any(
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()
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_postbox_targets,
)
from ..attachments.resolver import resolve_campaign_attachments
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]:
# 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:
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
except Exception as exc:
@@ -540,6 +540,12 @@ class CampaignVersion(Base, TimestampMixin):
"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
def strong_etag(self) -> str:
return strong_resource_etag(
@@ -6,6 +6,8 @@ from typing import Any, Mapping
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
@@ -26,6 +28,8 @@ class SynchronousSendPolicy:
source: str
deployment_max_recipient_jobs: int
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]:
return {
@@ -33,6 +37,9 @@ class SynchronousSendPolicy:
"source": self.source,
"deployment_max_recipient_jobs": self.deployment_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,
"tenant_setting": (
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
@@ -46,20 +53,36 @@ def effective_synchronous_send_policy(
*,
tenant_id: str,
environ: Mapping[str, str] | None = None,
apply_tenant_override: bool = True,
) -> SynchronousSendPolicy:
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(
env.get(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)
if tenant_raw is None:
return SynchronousSendPolicy(
max_recipient_jobs=deployment_value,
source=("deployment" if env.get(SYNCHRONOUS_SEND_MAX_ENV) not in (None, "") else "deployment_default"),
max_recipient_jobs=inherited,
source=inherited_source,
deployment_max_recipient_jobs=deployment_value,
system_max_recipient_jobs=system_value,
deployment_ceiling_explicit=deployment_explicit,
)
tenant_value = _configured_limit(
@@ -69,12 +92,14 @@ def effective_synchronous_send_policy(
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
),
)
effective_value = min(deployment_value, tenant_value)
effective_value = min(inherited, tenant_value)
return SynchronousSendPolicy(
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,
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 email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from types import SimpleNamespace
from typing import Any
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.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.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from govoplan_campaign.backend.messages.builder import BuiltMessage, CampaignBuildResult, build_campaign_messages
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.path_security import assert_server_safe_campaign_paths
@@ -151,6 +154,7 @@ def _mock_send_batch(
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
reviewed_keys: set[str] | None = None,
) -> _MockSendBatch:
batch = _MockSendBatch(results=[])
for built in built_messages:
@@ -160,7 +164,7 @@ def _mock_send_batch(
mailbox=mailbox,
send=send,
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,
)
batch.results.append(outcome.row)
@@ -391,6 +395,96 @@ def _build_mock_campaign_run(
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]:
payload = validation_report.model_dump(mode="json")
payload.update(
@@ -542,6 +636,7 @@ def run_mock_campaign_send(
append_sent: bool = True,
clear_mailbox: bool = False,
check_files: bool = False,
use_reviewed_build: bool = False,
) -> dict[str, Any]:
"""Validate, build and optionally mock-send a version without mutating it.
@@ -557,22 +652,29 @@ def run_mock_campaign_send(
campaign_id=campaign_id,
version_id=version_id,
)
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
validation_report, build_result, send_batch = _build_mock_campaign_run(
session,
tenant_id=tenant_id,
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,
)
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox and not use_reviewed_build)
if use_reviewed_build:
validation_report, build_result, send_batch = _build_reviewed_mock_run(
session, tenant_id=tenant_id, campaign=campaign, version=version,
mailbox=mailbox, send=send, include_warnings=include_warnings, append_sent=append_sent,
clear_mailbox=clear_mailbox,
)
else:
validation_report, build_result, send_batch = _build_mock_campaign_run(
session,
tenant_id=tenant_id,
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)
build_payload = _mock_build_payload(build_result)
return _mock_campaign_send_response(
result = _mock_campaign_send_response(
campaign=campaign,
version=version,
mailbox=mailbox,
@@ -586,3 +688,9 @@ def run_mock_campaign_send(
include_needs_review=include_needs_review,
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",
title="Create a campaign",
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,
audience=("campaign_manager", "campaign_author"),
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",
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.",
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,
audience=("campaign_manager", "campaign_author"),
required_modules=("campaigns", "files"),
@@ -743,7 +743,7 @@ CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
topic_id="campaigns.workflow.send-small-controlled-run",
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.",
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 0500 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,
audience=("campaign_sender", "campaign_operator"),
required_modules=("campaigns", "mail"),
@@ -800,7 +800,10 @@ CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
topic_id="campaigns.workflow.view-delivery-report",
title="Review campaign delivery details",
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,
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
@@ -7,11 +7,16 @@ from govoplan_core.core.modules import DocumentationTopic
_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 0500 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": {
"title": "Eine Kampagne anlegen",
"summary": "Eine gesteuerte Kampagne als bearbeitbaren Entwurf beginnen und Zweck sowie Eigentum vor Zustelldaten festlegen.",
"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": {
@@ -54,6 +59,7 @@ _TRANSLATIONS = {
"summary": "Gesteuerte Dateiversionen wählen, Regelzuordnungen prüfen und exakt verwendete Dateien im Build-Nachweis bewahren.",
"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."
" 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": {
@@ -74,7 +80,8 @@ _TRANSLATIONS = {
"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.",
"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 0500 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": {
@@ -89,6 +96,9 @@ _TRANSLATIONS = {
"summary": "Zustellsummen und empfängerbezogene Auftragsnachweise in der aktuellen Campaign-Berichtsoberfläche einsehen.",
"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."
" 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": {
@@ -158,20 +168,25 @@ _TRANSLATIONS = {
"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.",
"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": {
"title": "Campaign-zu-Mail-Profilreferenzen steuern",
"summary": "Mail besitzt Transportdefinitionen und verschlüsselte Zugangsdaten; Campaign nur die Profilreferenz und Zustellnachweise.",
"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": {
"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.",
"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."
),
},
@@ -180,13 +195,19 @@ _TRANSLATIONS = {
"summary": "Gesteuerte Empfänger-, Vorlagen-, Anhangs- und Mail-Profil-Eingaben in exakte Nachrichten zur Prüfung überführen.",
"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."
" 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": {
"title": "Die Kampagnenprüfung abschließen",
"summary": "Kritische Blocker lösen, einzelne Nachrichten entscheiden und unkritische Punkte für genau einen Build bestätigen.",
"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": {
@@ -194,13 +215,18 @@ _TRANSLATIONS = {
"summary": "Sicher wiederholbare Fehler von Mail-, Postbox- oder IMAP-Wirkungen mit unbekanntem Ergebnis trennen.",
"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."
" 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": {
"title": "Die Campaign-Referenzkomposition absichern",
"summary": "Campaign nur mit abgestimmten Verträgen, rollensicheren Oberflächen, dauerhaften Wirkungsnachweisen, optionaler Modultrennung und wiederherstellbaren Daten freigeben.",
"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."
" 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": {
@@ -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.",
"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."
" 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": {
@@ -5,7 +5,36 @@ from __future__ import annotations
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 '
'Kampagnenbearbeitung und einen '
'Moderator; bestätigen Sie die '
@@ -40,17 +69,13 @@ GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'campaigns.admin.co
'Mail-Einstellungen.',
'Wählen Sie ein verfügbares Mail-Profil aus; '
'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 '
'über das Mail-Modul.',
'Speichern, validieren und erstellen Sie die '
'Kampagne, bevor Sie die Lieferung in die '
'Warteschlange stellen.'],
'verification': 'Öffnen Sie die Mail-Einstellungen '
'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.'},
'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.'},
'campaigns.postbox-delivery': {'outcome': 'Jede aktive Zeile löst einen überprüfbaren Satz von '
'Postbox-Zielen auf, ohne eine harte '
'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 '
'Blocker, der Einzelüberprüfung und der '
'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 '
'benannten Kampagnenarbeitsbereich, validieren '
'und erstellen Sie ihn erneut.',
'Öffnen Sie jede verbleibende individuelle '
'Überprüfungsnachricht und notieren Sie ihre '
'Entscheidung.',
'Ö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.',
'Wenn nur nicht kritische Gruppenelemente '
'verbleiben, überprüfen Sie ihre Bedingungen und '
'schließen Sie die Überprüfung explizit ab.',
'Bestätigen Sie, dass Reviewed der gesamten '
'erforderlichen Review entspricht und Remaining '
'vor der Lieferung Null ist.'],
'verification': 'Überprüfen und senden Sie erneut, '
'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.'},
'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.'},
'campaigns.workflow.control-attachment-reuse': {'outcome': 'Ein Kampagnen-Build, dessen '
'wiederholte Verwendung von Anhängen '
'unter einer expliziten Richtlinie '
@@ -746,16 +765,9 @@ GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'campaigns.admin.co
'durchführen.',
'Provider, Mailbox, Arbeiter und '
'Kampagnenbeweise wurden bewahrt.'],
'steps': ['Klassifizieren Sie den Job und die letzten '
'SMTP- und IMAP-Versuche unabhängig '
'voneinander.',
'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.',
'steps': ['Öffnen Sie den Bericht der ausgewählten Version und klassifizieren Sie SMTP- und IMAP-Versuche unabhängig voneinander.',
'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.',
'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.',
'Überprüfen Sie den resultierenden '
'geschützten Zustand, bevor Sie mehr Arbeit '
'für diesen Job zulassen.'],
@@ -343,6 +343,28 @@ class MailCampaignIntegration:
except getattr(delegate, "MailProfileError", MailProfileError) as 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:
delegate = self._require()
try:
+87 -15
View File
@@ -463,8 +463,8 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.27",
workflow_definitions=campaign_workflow_definitions(module_version="0.1.27"),
version="0.1.28",
workflow_definitions=campaign_workflow_definitions(module_version="0.1.28"),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -727,6 +727,14 @@ manifest = ModuleManifest(
),
),
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(
id="campaigns.page.work",
module_id="campaigns",
@@ -1198,7 +1206,11 @@ manifest = ModuleManifest(
id="campaigns.mail-profile-user-journey",
title="Choose a Mail profile for campaign delivery",
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",
documentation_types=("user",),
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
@@ -1236,11 +1248,12 @@ manifest = ModuleManifest(
"steps": [
"Open the campaign and go to Mail settings.",
"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.",
"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.",
"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": [
"campaigns.mail-profile-governance",
"campaigns.mail-profile-operations",
@@ -1252,7 +1265,11 @@ manifest = ModuleManifest(
id="campaigns.mail-profile-governance",
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.",
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",
documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"),
@@ -1300,7 +1317,7 @@ manifest = ModuleManifest(
id="campaigns.mail-profile-operations",
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.",
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",
documentation_types=("admin",),
audience=("campaign_sender", "campaign_operator", "mail_admin"),
@@ -1348,7 +1365,10 @@ manifest = ModuleManifest(
id="campaigns.workflow.prepare-validate-and-build",
title="Prepare, validate, and build a campaign",
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",
documentation_types=("user",),
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.",
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 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"),
audience=("campaign_manager", "campaign_reviewer", "policy_admin"),
@@ -1443,13 +1465,36 @@ manifest = ModuleManifest(
"route": "/campaigns/{campaign_id}/files",
"screen": "Campaign attachments",
"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(
id="campaigns.workflow.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.",
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",
documentation_types=("user",),
audience=("campaign_reviewer",),
@@ -1486,13 +1531,14 @@ manifest = ModuleManifest(
],
"steps": [
"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.",
"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.",
"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.",
"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": [
"campaigns.workflow.prepare-validate-and-build",
"campaigns.workflow.retry-and-reconcile",
@@ -1503,7 +1549,9 @@ manifest = ModuleManifest(
id="campaigns.workflow.retry-and-reconcile",
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.",
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",
documentation_types=("admin", "user"),
audience=("campaign_sender", "campaign_operator"),
@@ -1548,9 +1596,9 @@ manifest = ModuleManifest(
"Provider, mailbox, worker, and campaign evidence has been preserved.",
],
"steps": [
"Classify the job and latest SMTP and IMAP attempts independently.",
"Retry only an explicitly temporary, permanent-with-override, or unattempted eligible state.",
"For an unknown effect, inspect provider or mailbox evidence and record the factual reconciliation with a note.",
"Open the selected version's Report and classify each job's SMTP and IMAP attempts independently.",
"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 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.",
],
"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 0500, 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(
id="campaigns.reference.composition-assurance",
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.",
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",
documentation_types=("admin",),
audience=(
@@ -8,6 +8,7 @@ from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import String, and_, cast, or_
from sqlalchemy.orm import Session
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.campaign.mail_profile_boundary import (
campaign_editor_state_for_edit,
campaign_editor_state_with_client_update,
campaign_mail_profile_boundary_violations,
campaign_mail_profile_id,
campaign_mail_references_unchanged,
campaign_preserves_legacy_mail_settings,
assert_campaign_uses_mail_profile_reference,
public_campaign_mail_server,
validate_campaign_editor_state,
@@ -41,6 +45,7 @@ from govoplan_campaign.backend.persistence.campaigns import (
normalize_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):
@@ -701,6 +706,12 @@ def _updated_runtime_json(
campaign_mail_profile_boundary_violations(version.raw_json)
)
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(
"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 "
@@ -712,6 +723,11 @@ def _updated_runtime_json(
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
"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(
session,
tenant_id=tenant_id,
@@ -745,7 +761,9 @@ def _apply_version_field_updates(
if value is not None:
setattr(version, field_name, value)
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:
version.autosaved_at = datetime.now(UTC)
@@ -908,6 +926,10 @@ def update_campaign_review_state(
reviewed_message_keys: list[str],
issue_decisions: list[dict[str, Any]] | None = 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,
) -> CampaignVersion:
"""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."
)
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(
dict.fromkeys(
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:
normalized_reviewed, normalized_decisions = _complete_campaign_review(
session,
version,
normalized_reviewed,
issue_decisions or [],
list(merged_decisions.values()) if merge_progress else requested,
user_id=user_id,
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(
version,
build_token=build_token,
@@ -961,6 +1026,43 @@ def update_campaign_review_state(
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:
build_summary = (
version.build_summary if isinstance(version.build_summary, dict) else {}
@@ -997,7 +1099,9 @@ def _complete_campaign_review(
blocking = [
job
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:
raise CampaignPersistenceError(
@@ -1039,7 +1143,7 @@ def _normalize_review_issue_decisions(
raise CampaignPersistenceError(
"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(
"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()
normalized: list[dict[str, Any]] = []
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 = [
issue
for issue in (job.issues_snapshot or [])
@@ -1134,7 +1242,7 @@ def _bulk_acceptable_review_keys(jobs: list[CampaignJob]) -> list[str]:
return [
str(job.entry_id or job.entry_index)
for job in jobs
if job.validation_status in {"warning", "excluded"}
if job.validation_status == "warning"
]
@@ -76,6 +76,8 @@ _SYNCHRONOUS_POLICY_KEYS = (
"source",
"deployment_max_recipient_jobs",
"tenant_max_recipient_jobs",
"system_max_recipient_jobs",
"deployment_ceiling_explicit",
)
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
_BUILD_SUMMARY_KEYS = (
+15 -1
View File
@@ -12,6 +12,8 @@ from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CAMPAIGN_MAIL_SERVER_KEYS,
campaign_mail_profile_id,
campaign_mail_references_unchanged,
campaign_preserves_legacy_mail_settings,
)
from govoplan_campaign.backend.archive_encryption import (
CampaignArchiveEncryptionError,
@@ -458,7 +460,18 @@ def _update_campaign_version_detail_response(
),
detail=str(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:
result = _campaign_version_detail_response(
session,
@@ -499,6 +512,7 @@ def _update_campaign_version_detail_response(
}
),
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
"legacy_mail_settings_preserved": preserves_legacy_mail,
"legacy_zipcrypto_acknowledgements": acknowledgements,
},
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
+2
View File
@@ -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.collaboration import router as collaboration_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.operations import router as operations_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()
for workflow_router in (
delivery_settings_router,
operations_router,
transfers_router,
campaigns_router,
@@ -12,6 +12,7 @@ from govoplan_campaign.backend.schemas import (
CampaignSendJobRequest,
CampaignSendUnattemptedRequest,
CampaignResolveOutcomeRequest,
CampaignRecoverClaimRequest,
CampaignDeliveryOptionsResponse,
MockCampaignSendRequest,
MockCampaignSendResponse,
@@ -84,6 +85,32 @@ router = APIRouter(prefix="/campaigns", tags=["campaigns"])
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(
"/{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)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or CampaignRetryJobsRequest()
if payload.run_inline:
_require_permission(principal, "campaigns:campaign:send")
_require_campaign_profile_use_if_needed(
session, principal, campaign_id, payload.version_id
)
@@ -255,6 +284,7 @@ def retry_campaign_jobs(
include_permanent=payload.include_permanent,
force_max_attempts=payload.force_max_attempts,
enqueue_celery=payload.enqueue_celery,
run_inline=payload.run_inline,
dry_run=payload.dry_run,
)
audit_from_principal(
@@ -265,10 +295,10 @@ def retry_campaign_jobs(
else "campaign.jobs_retry_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
details=_public_recovery_result(result),
commit=True,
)
return CampaignActionResponse(result=result)
return CampaignActionResponse(result=_public_recovery_result(result))
except (QueueingError, ExecutionSnapshotError) as exc:
raise HTTPException(
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)
_require_permission(principal, "campaigns:recipient:read")
payload = payload or CampaignSendUnattemptedRequest()
if payload.run_inline:
_require_permission(principal, "campaigns:campaign:send")
_require_campaign_profile_use_if_needed(
session, principal, campaign_id, payload.version_id
)
@@ -298,6 +330,7 @@ def send_unattempted_campaign_jobs(
version_id=payload.version_id,
job_ids=payload.job_ids or None,
enqueue_celery=payload.enqueue_celery,
run_inline=payload.run_inline,
dry_run=payload.dry_run,
)
audit_from_principal(
@@ -308,10 +341,10 @@ def send_unattempted_campaign_jobs(
else "campaign.unattempted_jobs_dry_run",
object_type="campaign",
object_id=campaign_id,
details=result,
details=_public_recovery_result(result),
commit=True,
)
return CampaignActionResponse(result=result)
return CampaignActionResponse(result=_public_recovery_result(result))
except (QueueingError, ExecutionSnapshotError) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
@@ -387,6 +420,34 @@ def send_single_campaign_job_endpoint(
) 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(
"/{campaign_id}/jobs/{job_id}/resolve-outcome",
response_model=CampaignActionResponse,
@@ -440,8 +501,9 @@ def mock_send_campaign(
):
"""Run a fully visible mock delivery flow without mutating campaign state.
The route validates and builds the selected version, then optionally records
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
Authoring previews validate and build transiently; reviewed-build mode
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.
"""
_get_campaign_for_principal(session, campaign_id, principal, write=True)
@@ -460,6 +522,7 @@ def mock_send_campaign(
send=payload.send,
include_warnings=payload.include_warnings,
include_needs_review=payload.include_needs_review,
use_reviewed_build=payload.use_reviewed_build,
append_sent=payload.append_sent,
clear_mailbox=payload.clear_mailbox,
check_files=payload.check_files,
@@ -475,6 +538,7 @@ def mock_send_campaign(
details={
"version_id": result.get("version_id"),
"send_requested": payload.send,
"use_reviewed_build": payload.use_reviewed_build,
"sent_count": result.get("send", {}).get("sent_count"),
"failed_count": result.get("send", {}).get("failed_count"),
},
@@ -703,30 +767,16 @@ def append_sent(
session: Session = Depends(get_session),
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()
version_ids = {
row[0]
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)
selected_version_id = payload.version_id or campaign.current_version_id
_require_campaign_profile_use_if_needed(session, principal, campaign_id, selected_version_id)
try:
result = enqueue_pending_imap_appends(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=selected_version_id,
enqueue_celery=payload.enqueue_celery,
run_inline=payload.run_inline,
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,
_calendar_invitations_for_jobs,
_job_detail_payload,
_job_page_recovery_metadata,
_job_diagnostics_payload,
)
@@ -425,6 +426,7 @@ def get_job_detail(
return CampaignJobDetailResponse(
job=_job_detail_payload(
job,
recovery=_job_page_recovery_metadata(session, [job]).get(job.id),
calendar_invitation=_calendar_invitations_for_jobs(
session,
[job],
@@ -6,6 +6,7 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
from sqlalchemy.orm import Session
from sqlalchemy.orm.exc import StaleDataError
from govoplan_campaign.backend.schemas import (
BuildCampaignRequest,
@@ -23,6 +24,7 @@ from govoplan_campaign.backend.schemas import (
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.object_storage import StorageBackendError
from govoplan_core.core.concurrency import RevisionConflictError
from govoplan_core.core.recovery import (
RecoveryGuaranteeError,
RecoveryMode,
@@ -581,6 +583,10 @@ def set_version_review_state(
for item in payload.issue_decisions
],
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,
)
audit_from_principal(
@@ -592,9 +598,17 @@ def set_version_review_state(
details={
"campaign_id": campaign_id,
"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),
"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,
)
@@ -602,6 +616,12 @@ def set_version_review_state(
version,
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:
session.rollback()
raise HTTPException(
@@ -1021,13 +1041,18 @@ def _archive_encryption_audit_evidence(value: object) -> dict[str, object]:
def _review_decision_audit_evidence(
version: CampaignVersion,
*,
job_ids: set[str] | None = None,
) -> dict[str, object]:
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
review_state = editor_state.get("review_send")
if not isinstance(review_state, dict):
return {}
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 = [
{
"decision": item.get("decision"),
+38 -2
View File
@@ -16,6 +16,7 @@ from pydantic import (
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
public_campaign_editor_state,
campaign_review_reference,
validate_campaign_editor_state,
)
from govoplan_campaign.backend.response_security import (
@@ -470,12 +471,22 @@ class CampaignReviewStateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
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(
default_factory=list,
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):
model_config = ConfigDict(extra="forbid")
@@ -491,6 +502,7 @@ class CampaignVersionResponse(BaseModel):
campaign_id: str
version_number: int
edit_revision: int = 1
review_build_token: str | None = None
strong_etag: str = ""
schema_version: str
source_filename: str | None = None
@@ -524,10 +536,15 @@ class CampaignVersionResponse(BaseModel):
def remove_unsupported_editor_state(
cls, value: Any, info: ValidationInfo
) -> dict[str, Any]:
return public_campaign_editor_state(
result = public_campaign_editor_state(
value,
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")
@classmethod
@@ -1093,6 +1110,7 @@ class CampaignRetryJobsRequest(BaseModel):
include_permanent: bool = False
force_max_attempts: bool = False
enqueue_celery: bool = True
run_inline: bool = False
dry_run: bool = False
@@ -1102,6 +1120,7 @@ class CampaignSendUnattemptedRequest(BaseModel):
version_id: str | None = None
job_ids: list[str] = Field(default_factory=list)
enqueue_celery: bool = True
run_inline: bool = False
dry_run: bool = False
@@ -1146,6 +1165,21 @@ class CampaignResolveOutcomeRequest(BaseModel):
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):
model_config = ConfigDict(extra="forbid")
@@ -1278,6 +1312,7 @@ class MockCampaignSendRequest(BaseModel):
send: bool = False
include_warnings: bool = True
include_needs_review: bool = False
use_reviewed_build: bool = False
append_sent: bool = True
clear_mailbox: bool = False
check_files: bool = False
@@ -1290,6 +1325,7 @@ class MockCampaignSendResponse(BaseModel):
class AppendSentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: str | None = None
enqueue_celery: bool = True
run_inline: bool = False
dry_run: bool = False
+248 -104
View File
@@ -4,7 +4,7 @@ import hashlib
import json
from collections import Counter
from contextlib import nullcontext
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, replace
from datetime import datetime, timezone
from email import policy
from email.parser import BytesParser
@@ -284,6 +284,9 @@ class AppendSentResult:
dry_run: bool = False
folder: 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]:
return {
@@ -293,6 +296,9 @@ class AppendSentResult:
"dry_run": self.dry_run,
"folder": self.folder,
"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
# where a concurrent queue operation could otherwise enlarge an immediate
# 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)
delivery_contexts = _preflight_synchronous_send_batch(
session,
@@ -1095,8 +1127,10 @@ def send_campaign_now(
jobs=jobs,
contexts=delivery_contexts,
)
batch_entered = False
try:
with batch_manager as smtp_batch:
batch_entered = True
# Queue state becomes durable only after local and SMTP
# DNS/connectivity/TLS/auth preflight succeeds.
session.commit()
@@ -1116,6 +1150,8 @@ def send_campaign_now(
sent_count += 1
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
outcome_unknown_count += 1
elif result.status in {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value, "failed"}:
failed_count += 1
else:
skipped_after_queue += 1
except Exception as exc:
@@ -1143,9 +1179,39 @@ def send_campaign_now(
smtp_reconnect_count = int(getattr(smtp_batch, "reconnect_count", 0) or 0)
except (MailProfileError, SmtpConfigurationError, SmtpSendError, OSError) as exc:
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(
"SMTP batch preflight could not validate DNS, connectivity, TLS, and authentication; no message was sent.",
f"{explanation}; no message was sent.",
reason=reason_code,
eligible_count=len(jobs),
policy=synchronous_policy,
@@ -1158,9 +1224,7 @@ def send_campaign_now(
sent_count=sent_count,
failed_count=failed_count,
outcome_unknown_count=outcome_unknown_count,
skipped_count=queue_result.skipped_count
+ queue_result.blocked_count
+ skipped_after_queue,
skipped_count=skipped_count + skipped_after_queue,
paused_count=paused_count,
batch_state=batch_state,
batch_pause_reason_code=pause_reason_code,
@@ -1518,6 +1582,7 @@ def queue_failed_jobs_for_retry(
include_permanent: bool = False,
force_max_attempts: bool = False,
enqueue_celery: bool = True,
run_inline: bool = False,
dry_run: bool = False,
) -> dict[str, Any]:
"""Queue known failures and incomplete multi-channel deliveries.
@@ -1549,7 +1614,12 @@ def queue_failed_jobs_for_retry(
version=version,
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(
{
"job_id": job.id,
@@ -1575,42 +1645,11 @@ def queue_failed_jobs_for_retry(
)
continue
selected.append(job)
if not dry_run:
job.queue_status = JobQueueStatus.QUEUED.value
job.send_status = JobSendStatus.QUEUED.value
job.queued_at = _utcnow()
job.claimed_at = None
job.claim_token = None
job.smtp_started_at = None
job.outcome_unknown_at = None
session.add(job)
if not dry_run:
if selected:
campaign.status = CampaignStatus.QUEUED.value
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
_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,
}
return _execute_explicit_delivery_selection(
session, campaign=campaign, version=version, selected=selected,
skipped=skipped, action="retry_failed", enqueue_celery=enqueue_celery,
run_inline=run_inline, dry_run=dry_run,
)
def queue_unattempted_jobs(
@@ -1621,6 +1660,7 @@ def queue_unattempted_jobs(
version_id: str | None = None,
job_ids: list[str] | None = None,
enqueue_celery: bool = True,
run_inline: bool = False,
dry_run: bool = False,
) -> dict[str, Any]:
"""Explicitly queue built jobs that have never started an SMTP attempt."""
@@ -1645,10 +1685,12 @@ def queue_unattempted_jobs(
eligible = (
job.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
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.validation_status in QUEUEABLE_VALIDATION_STATUSES
and _single_job_validation_allowed(version, job, include_warnings=True)
)
if not eligible:
skipped.append(
@@ -1659,41 +1701,86 @@ def queue_unattempted_jobs(
)
continue
selected.append(job)
if not dry_run:
job.queue_status = JobQueueStatus.QUEUED.value
job.send_status = JobSendStatus.QUEUED.value
job.queued_at = _utcnow()
job.claimed_at = None
job.claim_token = None
job.smtp_started_at = None
job.outcome_unknown_at = None
job.last_error = None
session.add(job)
return _execute_explicit_delivery_selection(
session, campaign=campaign, version=version, selected=selected,
skipped=skipped, action="send_unattempted", enqueue_celery=enqueue_celery,
run_inline=run_inline, dry_run=dry_run,
)
def _execute_explicit_delivery_selection(
session: Session, *, campaign: Campaign, version: CampaignVersion,
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 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:
_ensure_campaign_approval_gate(session, tenant_id=campaign.tenant_id, version=version)
claimed_selection = []
for job in selected:
_celery_enqueue_send_job(job.id)
enqueued += 1
return {
"campaign_id": campaign.id,
"version_id": version.id,
"action": "send_unattempted",
"selected_count": len(selected),
"enqueued_count": enqueued,
"skipped": skipped,
"dry_run": dry_run,
# A worker or another operator may have claimed the row after the
# selection query. Never reset that claim or an accepted attempt.
changed = session.query(CampaignJob).filter(
CampaignJob.id == job.id,
CampaignJob.tenant_id == campaign.tenant_id,
CampaignJob.send_status == job.send_status,
CampaignJob.queue_status == job.queue_status,
CampaignJob.attempt_count == job.attempt_count,
CampaignJob.postbox_attempt_count == job.postbox_attempt_count,
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(
@@ -2731,6 +2818,19 @@ def reconcile_job_outcome(
snapshot = ensure_execution_snapshot(session, version)
now = _utcnow()
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":
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.queue_status = JobQueueStatus.DRAFT.value
@@ -2926,6 +3026,8 @@ def _reconcile_imap_append_outcome(
raise QueueingError(
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 = (
session.query(ImapAppendAttempt)
@@ -2933,6 +3035,12 @@ def _reconcile_imap_append_outcome(
.order_by(ImapAppendAttempt.attempt_number.desc())
.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":
job.imap_status = JobImapStatus.APPENDED.value
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:
if job.eml_size_bytes is not None and len(payload) != job.eml_size_bytes:
raise SendJobError(
@@ -3615,10 +3739,10 @@ def _preflight_send_campaign_job(
message="A delivery outcome is unresolved; reconcile it before any retry.",
)
if job.send_status == JobSendStatus.SENDING.value:
return mark_job_outcome_unknown(
session,
job,
reason="A delivery task resumed while the previous channel attempt was still marked in progress. Automatic redelivery was stopped.",
return SendJobResult(
job_id=job.id, status="already_sending", attempt_number=job.attempt_count,
dry_run=dry_run,
message="Another runtime owns the active delivery. A stopped runtime's claim requires explicit guarded recovery.",
)
if job.send_status == JobSendStatus.CLAIMED.value:
return SendJobResult(
@@ -4983,13 +5107,19 @@ def _perform_imap_append(
)
raise ImapAppendError(reason, outcome_unknown=True) from None
try:
return _record_imap_append_success(
outcome = _record_imap_append_success(
session,
job=claimed.job,
attempt=claimed.attempt,
claim_token=claimed.claim_token,
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:
return _mark_imap_append_outcome_unknown_after_effect(
session,
@@ -5194,6 +5324,7 @@ def enqueue_pending_imap_appends(
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
enqueue_celery: bool = True,
run_inline: bool = False,
dry_run: bool = False,
@@ -5201,11 +5332,13 @@ def enqueue_pending_imap_appends(
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)
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.campaign_version_id == version.id,
CampaignJob.imap_status.in_(
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
),
@@ -5228,44 +5361,55 @@ def enqueue_pending_imap_appends(
results: list[dict[str, Any]] = []
appended_count = 0
failed_count = 0
outcome_unknown_count = 0
skipped_count = 0
connection_count = 0
reconnect_count = 0
if run_inline or dry_run:
for job in jobs:
try:
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
payload = result.as_dict()
results.append(payload)
if result.status == JobImapStatus.APPENDED.value:
appended_count += 1
elif result.status in {
"skipped",
"not_requested",
"not_sent",
"already_appended",
"dry_run",
}:
skipped_count += 1
except (
Exception
) as exc: # keep processing later jobs and expose per-job details
failed_count += 1
results.append(
{"job_id": job.id, "status": "failed", "message": str(exc)}
)
batch_context = nullcontext() if dry_run else mail_integration().campaign_imap_batch(tenant_id=tenant_id, campaign_id=campaign.id)
with batch_context as batch:
for job in jobs:
try:
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
payload = result.as_dict()
results.append(payload)
if result.status == JobImapStatus.APPENDED.value:
appended_count += 1
elif result.status == JobImapStatus.OUTCOME_UNKNOWN.value:
outcome_unknown_count += 1
elif result.status == JobImapStatus.FAILED.value:
failed_count += 1
else:
skipped_count += 1
except Exception as exc:
# An uncertain append remains frozen by its per-job
# pipeline. The batch never retries the same message.
uncertain = bool(getattr(exc, "outcome_unknown", False))
if uncertain:
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:
for job in jobs:
_celery_enqueue_append_sent_job(job.id)
return {
"campaign_id": campaign.id,
"version_id": version.id,
"pending_count": len(jobs),
"enqueued_count": len(jobs) if should_enqueue else 0,
"processed_count": len(results) if run_inline and not dry_run else 0,
"appended_count": appended_count,
"failed_count": failed_count,
"outcome_unknown_count": outcome_unknown_count,
"skipped_count": skipped_count,
"dry_run": dry_run,
"run_inline": run_inline,
"imap_connection_count": connection_count,
"imap_reconnect_count": reconnect_count,
"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 govoplan_campaign.backend.services.review_decisions import review_decision_metadata
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
import json
from collections.abc import Mapping, Sequence
from typing import Literal
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 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)
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(
job: CampaignJob,
*,
reviewed_keys: set[str] | None = None,
calendar_invitation: dict[str, object] | None = None,
recovery: dict[str, object] | None = None,
) -> dict[str, object]:
review_key = _job_review_key(job)
return {
@@ -81,6 +111,8 @@ def _job_summary_payload(
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"resolved_recipients": _public_recipient_groups(getattr(job, "resolved_recipients", None)),
"recovery": recovery or {},
"subject": job.subject,
"message_id_header": job.message_id_header,
"build_status": job.build_status,
@@ -114,6 +146,7 @@ def _job_summary_payload(
"attachment_count": len(job.resolved_attachments or []),
"review_key": review_key,
"reviewed": review_key in reviewed_keys if reviewed_keys is not None else False,
"review_decision": review_decision_metadata(job),
"matched_file_count": sum(
len(item.get("matches") or [])
for item in (job.resolved_attachments or [])
@@ -128,9 +161,10 @@ def _job_detail_payload(
job: CampaignJob,
*,
calendar_invitation: dict[str, object] | None = None,
recovery: dict[str, object] | None = None,
) -> dict[str, object]:
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,
"issues": job.issues_snapshot or [],
"attachments": public_campaign_payload(job.resolved_attachments or []),
@@ -561,13 +595,13 @@ def _review_metadata_counts(
bulk_acceptable_count = 0
for entry_id, entry_index, build_status, validation_status in review_rows:
key = str(entry_id or entry_index)
if build_status != "built" or validation_status == "blocked":
if validation_status == "blocked" or (build_status != "built" and validation_status not in {"excluded", "inactive"}):
blocking_count += 1
if validation_status == "needs_review":
required_count += 1
if key in reviewed_keys:
reviewed_required_count += 1
elif validation_status in {"warning", "excluded"}:
elif validation_status == "warning":
bulk_acceptable_count += 1
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(
grid_filters: dict[str, str] | None,
) -> list[object]:
@@ -645,10 +695,7 @@ def _campaign_jobs_grid_filter_expressions(
if recipient:
pattern = _contains_pattern(recipient)
expressions.append(
or_(
CampaignJob.recipient_email.ilike(pattern, escape="\\"),
CampaignJob.entry_id.ilike(pattern, escape="\\"),
)
_campaign_recipient_search_expression(pattern)
)
subject = values.get("subject", "").strip()
if subject:
@@ -801,12 +848,11 @@ def _campaign_jobs_query_context(
if imap_status:
filtered.append(CampaignJob.imap_status.in_(imap_status))
if query_text and query_text.strip():
pattern = f"%{query_text.strip()}%"
pattern = _contains_pattern(query_text.strip())
filtered.append(
or_(
CampaignJob.recipient_email.ilike(pattern),
CampaignJob.subject.ilike(pattern),
CampaignJob.entry_id.ilike(pattern),
_campaign_recipient_search_expression(pattern),
CampaignJob.subject.ilike(pattern, escape="\\"),
)
)
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:
jobs = [job for job in jobs if job.id in changed_job_ids]
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
recovery = _job_page_recovery_metadata(session, jobs)
return CampaignJobsResponse(
jobs=[
_job_summary_payload(
job,
reviewed_keys=reviewed_keys,
calendar_invitation=calendar_invitations.get(job.id),
recovery=recovery.get(job.id),
)
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")}),
}