Compare commits

..
3 Commits
Author SHA1 Message Date
zemion 5a21067e44 feat(campaigns): add Case Quick Access selector
Module Package Release / publish-packages (push) Successful in 13s
2026-08-21 16:05:45 +02:00
zemion 73cfad209a feat: add governed Campaign DSAR coverage 2026-08-20 23:15:11 +02:00
zemion 2a00d910df feat: govern autonomous campaign delivery schedules 2026-08-20 21:10:11 +02:00
22 changed files with 3562 additions and 69 deletions
+17
View File
@@ -59,6 +59,20 @@ Files, Mail, Distribution Lists, Templates, Postbox, and Calendar are optional m
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
Recurring schedules have two immutable modes. Manual mode remains the default
and prepares independent drafts without Mail. Autonomous mode is explicit and
Mail-only: it seals an already built and explicitly approved execution snapshot,
rechecks approval, policy, credential/transport revision, live SMTP health,
recipient and attachment evidence before each occurrence, and submits one
Mail-owned durable command per frozen message. Occurrence-scoped idempotency is
allocated before delivery. Accepted and outcome-unknown effects are never
retried automatically; uncertain or systemic failures pause the schedule,
notify its accountable operator, and retain non-secret recovery evidence.
Generated EML retention excludes source versions while an autonomous schedule
has a remaining occurrence, including while it is paused; once the schedule
finishes, already accepted Mail commands retain their own encrypted payload and
evidence under Mail policy.
Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or
worker claim tokens. Operational troubleshooting uses the dedicated job
@@ -78,6 +92,9 @@ services can cooperate without importing campaign internals:
- `campaigns.policyContext` for retention/policy provenance
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
- `campaigns.retention` for campaign-owned retention cleanup
- `privacy.dsar.campaigns` for tenant-scoped recipient, version, delivery,
report-projection, and artifact-metadata discovery plus governed erasure
planning
Keep these capability payloads narrow: stable ids, policy payloads, and task
results only.
+24
View File
@@ -557,6 +557,30 @@ purpose, lawful basis, minimization, export control, and retention before the
campaign starts; do not use Campaign as a substitute consent or address-master
system.
The Core data-subject-request workflow discovers Campaign through the optional
`privacy.dsar.campaigns` capability. After the request's email, membership, and
namespaced Campaign references have been independently authorized and
corroborated, the provider searches only the effective tenant and isolates the
matching recipient entries and jobs. Its JSON result includes safe Campaign,
version, delivery-attempt, schedule, report-projection, share, import-mapping,
attachment, and generated-artifact metadata. Generated EML bytes and paths,
storage locators, delivery target snapshots, worker claims, idempotency
material, credentials, secret-like values, and unrelated recipients are never
embedded in that result. Authorized Campaign and Files review surfaces remain
the source for content that cannot safely be copied into the DSAR case.
Built, locked, published, terminal, delivered, or corrected records are
retained with an explicit reason and continue through Campaign's configured
retention and redaction process. Draft recipient content and user-owned
attachment content require coordinated manual review because copies may span
version JSON, jobs, generated messages, and managed files. The provider can
idempotently revoke an active share aimed at the subject and delete the
subject's personal recipient-import mapping profile. It does not rewrite
delivery evidence, delete generated artifacts, or report derived Campaign
counts as a separate store. Re-running an approved action is safe: already
revoked or absent data is reported as unchanged, and tenant, subject, and row
ownership are revalidated immediately before mutation.
### Audit and destructive actions
Material authoring, validation, locking, review, queueing, send, retry,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.18"
version = "0.1.19"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
@@ -6,6 +6,8 @@ import hashlib
import json
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
from email import policy
from email.parser import BytesParser
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy.orm import Session
@@ -13,19 +15,35 @@ from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
CampaignVersion,
JobBuildStatus,
)
from govoplan_campaign.backend.approval_gate import (
assert_campaign_approval,
campaign_approval_gate,
)
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
from govoplan_campaign.backend.integrations import mail_integration
from govoplan_campaign.backend.persistence.campaigns import (
create_campaign_version_from_json,
)
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
from govoplan_campaign.backend.sending.jobs import (
_from_header_from_job,
_send_job_delivery_context,
_single_job_validation_allowed,
_synchronous_smtp_batch_manager,
)
from govoplan_core.audit.logging import audit_event
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
SCHEDULE_DELIVERY_MODES = frozenset({"manual", "autonomous"})
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
@@ -96,6 +114,11 @@ def dispatch_due_campaign_schedules(
limit: int = 50,
) -> dict[str, object]:
observed_at = _as_utc(now or datetime.now(UTC))
refreshed = refresh_autonomous_schedule_outcomes(
session,
tenant_id=tenant_id,
now=observed_at,
)
query = session.query(CampaignSchedule).filter(
CampaignSchedule.active.is_(True),
CampaignSchedule.next_fire_at.is_not(None),
@@ -112,39 +135,95 @@ def dispatch_due_campaign_schedules(
result: dict[str, object] = {
"selected": len(schedules),
"prepared": 0,
"autonomous_prepared": 0,
"failed": 0,
"completed": 0,
"coalesced": 0,
"duplicates": 0,
"deferred": 0,
"campaign_ids": [],
"operator_actions": [],
"refreshed": refreshed,
}
for schedule in schedules:
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
if schedule.delivery_mode == "autonomous" and _has_open_occurrence(
session, schedule_id=schedule.id
):
result["deferred"] = int(result["deferred"]) + 1
continue
try:
with session.begin_nested():
campaign, version, skipped = _prepare_occurrence(
session,
schedule=schedule,
scheduled_for=scheduled_for,
observed_at=observed_at,
)
if schedule.delivery_mode == "autonomous":
_occurrence, skipped = _prepare_autonomous_occurrence(
session,
schedule=schedule,
scheduled_for=scheduled_for,
observed_at=observed_at,
)
campaign_id = schedule.campaign_id
result["autonomous_prepared"] = (
int(result["autonomous_prepared"]) + 1
)
else:
campaign, _version, skipped = _prepare_occurrence(
session,
schedule=schedule,
scheduled_for=scheduled_for,
observed_at=observed_at,
)
campaign_id = campaign.id
result["prepared"] = int(result["prepared"]) + 1
result["coalesced"] = int(result["coalesced"]) + skipped
result["campaign_ids"].append(campaign.id) # type: ignore[union-attr]
result["campaign_ids"].append(campaign_id) # type: ignore[union-attr]
if not schedule.active:
result["completed"] = int(result["completed"]) + 1
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
session.expire_all()
recorded = (
session.query(CampaignScheduleOccurrence)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
)
.one_or_none()
)
if recorded is not None:
result["duplicates"] = int(result["duplicates"]) + 1
if (
schedule.active
and schedule.next_fire_at is not None
and _as_utc(schedule.next_fire_at) == scheduled_for
and recorded.status not in {"failed", "uncertain"}
):
_advance_schedule(
session,
schedule=schedule,
occurrence=recorded,
scheduled_for=scheduled_for,
observed_at=observed_at,
sequence=schedule.occurrence_count + 1,
)
continue
session.add(
CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=scheduled_for,
status="failed",
idempotency_key=_occurrence_idempotency_key(
schedule.id, scheduled_for
),
error=str(exc)[:4000],
recovery_state="failed",
evidence={"delivery_mode": schedule.delivery_mode},
last_checked_at=observed_at,
)
)
schedule.active = False
schedule.last_error = str(exc)[:4000]
schedule.last_outcome = "failed"
schedule.last_recovery_state = "operator_required"
schedule.resource_revision += 1
session.add(schedule)
result["failed"] = int(result["failed"]) + 1
@@ -153,8 +232,14 @@ def dispatch_due_campaign_schedules(
"schedule_id": schedule.id,
"campaign_id": schedule.campaign_id,
"reason": "draft_preparation_failed",
"delivery_mode": schedule.delivery_mode,
}
)
_notify_schedule_operator(
session,
schedule=schedule,
reason="policy_or_systemic_preflight_failed",
)
session.flush()
return result
@@ -234,41 +319,26 @@ def _prepare_occurrence(
schedule_id=schedule.id,
scheduled_for=scheduled_for,
status="prepared",
idempotency_key=_occurrence_idempotency_key(schedule.id, scheduled_for),
generated_campaign_id=generated_campaign.id,
generated_version_id=generated_version.id,
recovery_state="none",
evidence={"delivery_mode": "manual"},
last_checked_at=observed_at,
)
session.add(occurrence)
schedule.occurrence_count = sequence
schedule.last_fired_at = scheduled_for
session.flush()
schedule.last_campaign_id = generated_campaign.id
schedule.last_error = None
next_fire = next_schedule_fire(
scheduled_for,
recurrence_kind=schedule.recurrence_kind,
interval_count=schedule.interval_count,
timezone_name=schedule.timezone,
schedule.last_outcome = "prepared"
schedule.last_recovery_state = "none"
coalesced = _advance_schedule(
session,
schedule=schedule,
occurrence=occurrence,
scheduled_for=scheduled_for,
observed_at=observed_at,
sequence=sequence,
)
coalesced = 0
while next_fire is not None and next_fire <= observed_at:
next_fire = next_schedule_fire(
next_fire,
recurrence_kind=schedule.recurrence_kind,
interval_count=schedule.interval_count,
timezone_name=schedule.timezone,
)
coalesced += 1
if (
next_fire is None
or sequence >= schedule.max_occurrences
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
):
schedule.active = False
schedule.next_fire_at = None
else:
schedule.next_fire_at = next_fire
schedule.resource_revision += 1
session.add(schedule)
audit_event(
session,
tenant_id=schedule.tenant_id,
@@ -291,6 +361,535 @@ def _prepare_occurrence(
return generated_campaign, generated_version, coalesced
def validate_autonomous_schedule_source(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
) -> dict[str, object]:
"""Validate the exact immutable execution that an autonomous schedule reuses."""
gate = campaign_approval_gate(version)
if gate is None:
raise RuntimeError(
"Autonomous delivery requires an explicit Approval request for the built source version."
)
assert_campaign_approval(session, tenant_id=campaign.tenant_id, version=version)
snapshot = ensure_execution_snapshot(session, version)
snapshot_hash = str(version.execution_snapshot_hash or "")
if len(snapshot_hash) != 64:
raise RuntimeError("The approved Campaign execution snapshot is incomplete.")
jobs = _autonomous_source_jobs(
session,
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
version=version,
)
mail = mail_integration()
if not mail.durable_delivery_available:
raise RuntimeError(
"Autonomous delivery requires Mail's durable delivery-command outbox."
)
if not snapshot.mail_profile_id or not snapshot.smtp_transport_revision:
raise RuntimeError(
"The approved Campaign execution has no immutable Mail transport evidence."
)
summary = mail.campaign_profile_delivery_summary(
session,
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
profile_id=snapshot.mail_profile_id,
smtp_server_id=snapshot.smtp_server_id,
smtp_credential_id=snapshot.smtp_credential_id,
)
if not summary.get("smtp_available"):
raise RuntimeError("The approved Campaign Mail transport is unavailable.")
if summary.get("smtp_transport_revision") != snapshot.smtp_transport_revision:
raise RuntimeError(
"The Campaign Mail transport changed after approval; rebuild and approve a new source version."
)
return {
"execution_snapshot_hash": snapshot_hash,
"approval_request_id": str(gate.get("request_id") or ""),
"approval_subject_digest": str(gate.get("subject_digest") or ""),
"job_count": len(jobs),
"job_manifest_sha256": canonical_configuration_hash(
{"jobs": [{"id": job.id, "eml_sha256": job.eml_sha256} for job in jobs]}
),
}
def _autonomous_source_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion,
) -> list[CampaignJob]:
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
CampaignJob.campaign_version_id == version.id,
)
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
.all()
)
if not jobs:
raise RuntimeError(
"Autonomous delivery requires a built source version with recipient jobs."
)
for job in jobs:
if job.build_status != JobBuildStatus.BUILT.value:
raise RuntimeError(
"Autonomous delivery requires every source message to be built."
)
if not _single_job_validation_allowed(version, job, include_warnings=True):
raise RuntimeError(
"Autonomous delivery requires every source message to pass its reviewed recipient and attachment gates."
)
if DeliveryChannelPolicy(job.delivery_channel_policy) != DeliveryChannelPolicy.MAIL:
raise RuntimeError(
"Autonomous schedules currently support Mail-only delivery; use manual mode for hybrid, Postbox, or print delivery."
)
return jobs
def _prepare_autonomous_occurrence(
session: Session,
*,
schedule: CampaignSchedule,
scheduled_for: datetime,
observed_at: datetime,
) -> tuple[CampaignScheduleOccurrence, int]:
existing = (
session.query(CampaignScheduleOccurrence)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
)
.one_or_none()
)
if existing is not None:
raise RuntimeError("Campaign schedule occurrence was already recorded")
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
campaign = session.get(Campaign, schedule.campaign_id)
version = session.get(CampaignVersion, schedule.source_version_id)
if campaign is None or campaign.tenant_id != schedule.tenant_id:
raise RuntimeError("Campaign schedule source is no longer available")
if version is None or version.campaign_id != campaign.id:
raise RuntimeError("Campaign schedule source version is no longer available")
validation = validate_autonomous_schedule_source(
session,
campaign=campaign,
version=version,
)
if (
not schedule.approved_execution_snapshot_hash
or validation["execution_snapshot_hash"]
!= schedule.approved_execution_snapshot_hash
):
raise RuntimeError(
"The approved Campaign execution changed after the autonomous schedule was created."
)
jobs = _autonomous_source_jobs(
session,
tenant_id=schedule.tenant_id,
campaign_id=campaign.id,
version=version,
)
occurrence_key = _occurrence_idempotency_key(schedule.id, scheduled_for)
occurrence = CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=scheduled_for,
status="preparing",
idempotency_key=occurrence_key,
recovery_state="prepared",
evidence={
"delivery_mode": "autonomous",
"source_campaign_id": campaign.id,
"source_version_id": version.id,
"source_snapshot_hash": schedule.source_snapshot_hash,
**validation,
},
last_checked_at=observed_at,
)
session.add(occurrence)
session.flush()
contexts = {job.id: _send_job_delivery_context(session, job) for job in jobs}
with _synchronous_smtp_batch_manager(session, jobs=jobs, contexts=contexts):
pass
mail = mail_integration()
commands: list[dict[str, object]] = []
for job in jobs:
context = contexts[job.id]
if context.envelope_from is None or not context.envelope_recipients:
raise RuntimeError("A frozen Campaign message has no delivery envelope.")
message = BytesParser(policy=policy.default).parsebytes(context.message_bytes)
commands.append(
mail.submit_delivery_command(
session,
tenant_id=schedule.tenant_id,
command_type="campaign_schedule_occurrence",
source_module="campaigns",
source_resource_type="campaign",
source_resource_id=campaign.id,
source_version_id=version.id,
idempotency_key=f"{occurrence_key}:{job.id}",
profile_id=context.snapshot.mail_profile_id,
message_bytes=context.message_bytes,
envelope_from=context.envelope_from,
envelope_recipients=context.envelope_recipients,
from_header=_from_header_from_job(job) or str(message.get("From") or ""),
expected_smtp_transport_revision=(
context.snapshot.smtp_transport_revision or ""
),
smtp_server_id=context.snapshot.smtp_server_id,
smtp_credential_id=context.snapshot.smtp_credential_id,
created_by_user_id=schedule.created_by_user_id,
)
)
occurrence.delivery_command_ids = [str(item["id"]) for item in commands]
occurrence.status = "prepared"
occurrence.recovery_state = "pending"
occurrence.evidence = {
**occurrence.evidence,
"command_count": len(commands),
"duplicate_command_count": sum(bool(item.get("duplicate")) for item in commands),
"command_status_counts": _status_counts(commands),
}
occurrence.last_checked_at = observed_at
sequence = schedule.occurrence_count + 1
schedule.last_campaign_id = campaign.id
schedule.last_outcome = "prepared"
schedule.last_recovery_state = "pending"
coalesced = _advance_schedule(
session,
schedule=schedule,
occurrence=occurrence,
scheduled_for=scheduled_for,
observed_at=observed_at,
sequence=sequence,
)
audit_event(
session,
tenant_id=schedule.tenant_id,
user_id=schedule.created_by_user_id,
action="campaign.schedule.delivery_prepared",
object_type="campaign_schedule_occurrence",
object_id=occurrence.id,
details={
"schedule_id": schedule.id,
"campaign_id": campaign.id,
"source_version_id": version.id,
"scheduled_for": scheduled_for.isoformat(),
"occurrence_idempotency_key": occurrence_key,
"delivery_command_count": len(commands),
"execution_snapshot_hash": validation["execution_snapshot_hash"],
"approval_request_id": validation["approval_request_id"],
"coalesced_missed_intervals": coalesced,
},
commit=False,
)
return occurrence, coalesced
def _advance_schedule(
session: Session,
*,
schedule: CampaignSchedule,
occurrence: CampaignScheduleOccurrence,
scheduled_for: datetime,
observed_at: datetime,
sequence: int,
) -> int:
schedule.occurrence_count = sequence
schedule.last_fired_at = scheduled_for
schedule.last_error = None
next_fire = next_schedule_fire(
scheduled_for,
recurrence_kind=schedule.recurrence_kind,
interval_count=schedule.interval_count,
timezone_name=schedule.timezone,
)
coalesced = 0
while next_fire is not None and next_fire <= observed_at:
session.add(
CampaignScheduleOccurrence(
tenant_id=schedule.tenant_id,
schedule_id=schedule.id,
scheduled_for=next_fire,
status="superseded",
idempotency_key=_occurrence_idempotency_key(schedule.id, next_fire),
recovery_state="superseded",
evidence={
"delivery_mode": schedule.delivery_mode,
"reason": "coalesced_missed_interval",
"superseded_by_occurrence_id": occurrence.id,
},
last_checked_at=observed_at,
)
)
next_fire = next_schedule_fire(
next_fire,
recurrence_kind=schedule.recurrence_kind,
interval_count=schedule.interval_count,
timezone_name=schedule.timezone,
)
coalesced += 1
if (
next_fire is None
or sequence >= schedule.max_occurrences
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
):
schedule.active = False
schedule.next_fire_at = None
else:
schedule.next_fire_at = next_fire
schedule.resource_revision += 1
session.add(schedule)
return coalesced
def refresh_autonomous_schedule_outcomes(
session: Session,
*,
tenant_id: str | None = None,
now: datetime | None = None,
) -> dict[str, int]:
observed_at = _as_utc(now or datetime.now(UTC))
query = session.query(CampaignScheduleOccurrence).filter(
CampaignScheduleOccurrence.status.in_(("prepared", "uncertain")),
)
if tenant_id is not None:
query = query.filter(CampaignScheduleOccurrence.tenant_id == tenant_id)
counts = {
"checked": 0,
"accepted": 0,
"uncertain": 0,
"failed": 0,
"skipped": 0,
}
mail = mail_integration()
if not mail.durable_delivery_available:
for occurrence in query.order_by(
CampaignScheduleOccurrence.created_at
).limit(250):
if not occurrence.delivery_command_ids:
continue
counts["checked"] += 1
counts["uncertain"] += 1
_mark_occurrence_uncertain(
session,
occurrence=occurrence,
observed_at=observed_at,
reason="mail_delivery_outbox_unavailable",
)
return counts
for occurrence in query.order_by(CampaignScheduleOccurrence.created_at).limit(250):
if not occurrence.delivery_command_ids:
continue
summaries: list[dict[str, object]] = []
try:
summaries = [
mail.delivery_command_summary(
session,
tenant_id=occurrence.tenant_id,
command_id=command_id,
)
for command_id in occurrence.delivery_command_ids
]
except Exception:
counts["checked"] += 1
counts["uncertain"] += 1
_mark_occurrence_uncertain(
session,
occurrence=occurrence,
observed_at=observed_at,
reason="mail_delivery_status_unavailable",
)
continue
counts["checked"] += 1
outcome, recovery_state = _aggregate_command_outcome(summaries)
previous_outcome = occurrence.status
previous_recovery_state = occurrence.recovery_state
occurrence.status = outcome
occurrence.recovery_state = recovery_state
occurrence.last_checked_at = observed_at
occurrence.evidence = {
**(occurrence.evidence or {}),
"command_status_counts": _status_counts(summaries),
"accepted_recipient_count": sum(
int(item.get("accepted_count") or 0) for item in summaries
),
"refused_recipient_count": sum(
int(item.get("refused_count") or 0) for item in summaries
),
"failure_codes": sorted(
{
str(item["failure_code"])
for item in summaries
if item.get("failure_code")
}
),
}
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
if schedule is not None:
schedule.last_outcome = outcome
schedule.last_recovery_state = recovery_state
transitioned_to_operator_required = (
outcome in {"uncertain", "failed"}
and (
previous_outcome != outcome
or previous_recovery_state != recovery_state
or schedule.active
)
)
if transitioned_to_operator_required:
schedule.active = False
schedule.last_error = (
"Autonomous delivery needs operator review; automatic recurrence is paused."
)
schedule.resource_revision += 1
_notify_schedule_operator(
session,
schedule=schedule,
reason=f"delivery_{outcome}",
)
session.add(schedule)
session.add(occurrence)
if outcome in counts:
counts[outcome] += 1
return counts
def _mark_occurrence_uncertain(
session: Session,
*,
occurrence: CampaignScheduleOccurrence,
observed_at: datetime,
reason: str,
) -> None:
previous_outcome = occurrence.status
previous_recovery_state = occurrence.recovery_state
occurrence.status = "uncertain"
occurrence.recovery_state = "operator_required"
occurrence.last_checked_at = observed_at
occurrence.evidence = {
**(occurrence.evidence or {}),
"recovery_reason": reason,
}
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
if schedule is not None:
transitioned = (
previous_outcome != "uncertain"
or previous_recovery_state != "operator_required"
or schedule.active
)
schedule.active = False
schedule.last_outcome = "uncertain"
schedule.last_recovery_state = "operator_required"
schedule.last_error = (
"Autonomous delivery status is unavailable; automatic recurrence is paused."
)
if transitioned:
schedule.resource_revision += 1
_notify_schedule_operator(
session,
schedule=schedule,
reason=reason,
)
session.add(schedule)
session.add(occurrence)
def _has_open_occurrence(session: Session, *, schedule_id: str) -> bool:
rows = (
session.query(CampaignScheduleOccurrence.delivery_command_ids)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule_id,
CampaignScheduleOccurrence.status == "prepared",
)
.limit(1000)
.all()
)
return any(bool(command_ids) for (command_ids,) in rows)
def _aggregate_command_outcome(
summaries: list[dict[str, object]],
) -> tuple[str, str]:
statuses = {str(item.get("status") or "") for item in summaries}
if statuses and statuses <= {"accepted", "reconciled_accepted"}:
return "accepted", "complete"
if statuses and statuses <= {"reconciled_not_accepted"}:
return "skipped", "reconciled"
if statuses & {"outcome_unknown", "in_progress"}:
return "uncertain", "operator_required"
if statuses & {"permanent_failure", "partially_refused", "reconciled_not_accepted"}:
return "failed", "operator_required"
return "prepared", "pending"
def _status_counts(items: list[dict[str, object]]) -> dict[str, int]:
result: dict[str, int] = {}
for item in items:
status = str(item.get("status") or "unknown")
result[status] = result.get(status, 0) + 1
return result
def _occurrence_idempotency_key(schedule_id: str, scheduled_for: datetime) -> str:
return f"campaign-schedule:{schedule_id}:{_as_utc(scheduled_for).isoformat()}"
def _notify_schedule_operator(
session: Session,
*,
schedule: CampaignSchedule,
reason: str,
) -> None:
from govoplan_core.core.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_campaign.backend.runtime import get_registry
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
try:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=schedule.tenant_id,
source_module="campaigns",
source_resource_type="campaign_schedule",
source_resource_id=schedule.id,
event_kind="campaign.schedule.operator_required",
channel="inbox",
recipient_type="user" if schedule.created_by_user_id else None,
recipient_id=schedule.created_by_user_id,
subject=f"Campaign schedule paused: {schedule.name}",
body_text=(
"Autonomous Campaign delivery was paused before another occurrence. "
"Review its recovery evidence before resuming."
),
action_url=f"/campaigns/{schedule.campaign_id}",
priority=2,
payload={"schedule_id": schedule.id, "reason": reason},
),
enqueue_delivery=False,
)
except Exception:
return
def _copy_snapshot_shares(
session: Session,
*,
@@ -360,4 +959,6 @@ __all__ = [
"canonical_configuration_hash",
"dispatch_due_campaign_schedules",
"next_schedule_fire",
"refresh_autonomous_schedule_outcomes",
"validate_autonomous_schedule_source",
]
@@ -192,6 +192,12 @@ class CampaignSchedule(Base, TimestampMixin):
index=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
delivery_mode: Mapped[str] = mapped_column(
String(20),
default="manual",
nullable=False,
index=True,
)
recurrence_kind: Mapped[str] = mapped_column(
String(20),
default="once",
@@ -210,6 +216,9 @@ class CampaignSchedule(Base, TimestampMixin):
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
approved_execution_snapshot_hash: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True
)
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_campaign_id: Mapped[str | None] = mapped_column(
@@ -218,6 +227,10 @@ class CampaignSchedule(Base, TimestampMixin):
index=True,
)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
last_outcome: Mapped[str | None] = mapped_column(String(30), nullable=True)
last_recovery_state: Mapped[str | None] = mapped_column(
String(30), nullable=True
)
class CampaignScheduleOccurrence(Base, TimestampMixin):
@@ -240,6 +253,9 @@ class CampaignScheduleOccurrence(Base, TimestampMixin):
)
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
idempotency_key: Mapped[str | None] = mapped_column(
String(200), nullable=True, index=True
)
generated_campaign_id: Mapped[str | None] = mapped_column(
ForeignKey("campaigns.id", ondelete="SET NULL"),
nullable=True,
@@ -251,6 +267,16 @@ class CampaignScheduleOccurrence(Base, TimestampMixin):
index=True,
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
delivery_command_ids: Mapped[list[str]] = mapped_column(
JSON, default=list, nullable=False
)
recovery_state: Mapped[str] = mapped_column(
String(30), default="none", nullable=False, index=True
)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
last_checked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class RecipientImportMappingProfile(Base, TimestampMixin):
+15 -13
View File
@@ -236,9 +236,9 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
_workflow_topic(
topic_id="campaigns.workflow.schedule-drafts",
title="Schedule bounded recurring campaign drafts",
summary="Prepare fresh campaign drafts at a future time without bypassing validation, review, approval, or delivery controls.",
body="A Campaign schedule stores an integrity-sealed snapshot of the selected version, campaign policy, Mail profile policy, and optional shares, plus a bounded one-time, daily, weekly, or monthly recurrence. Each due occurrence creates a separately owned draft and an occurrence record. Missed intervals are coalesced instead of producing a catch-up storm. A schedule never validates, approves, queues, retries, or sends a campaign, and a preparation failure pauses it for operator review. Pause and resume reject stale browser state.",
title="Schedule bounded manual or autonomous campaigns",
summary="Prepare fresh drafts or explicitly opt in to governed delivery of an exact approved build.",
body="Every schedule is bounded, timezone-aware, and fixed to either manual or autonomous mode. Manual mode stores an integrity-sealed configuration snapshot and prepares a separately owned draft per due occurrence without requiring Mail. Autonomous mode never rebuilds or silently changes approved content: it requires a built Mail-only source version with an explicit valid Approval request, seals its execution-snapshot hash, and rechecks approval, policy, credential selection, SMTP transport revision and live transport health, recipient gates, attachment evidence, and snapshot integrity before every occurrence. It then creates one Mail-owned durable command per frozen message with occurrence-scoped idempotency before delivery. Mail never automatically retries accepted or outcome-unknown effects. Campaign records prepared, accepted, uncertain, failed, skipped, and superseded recovery evidence; uncertain, policy, configuration, and systemic failures pause the recurrence and notify the accountable operator. Missed intervals are coalesced instead of causing a catch-up storm, and pause/resume rejects stale browser state.",
order=34,
audience=("campaign_manager", "campaign_author", "operator"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
@@ -249,24 +249,26 @@ CAMPAIGN_USER_DOCUMENTATION = (
"Choose the exact campaign version whose configuration should seed future drafts.",
"Recipient data and active shares require their corresponding read or share authority.",
"A worker and scheduler process must be running for automatic due-time preparation.",
"Autonomous mode additionally requires campaigns:campaign:queue, campaigns:campaign:send, mail:profile:use, Mail's durable outbox, and an explicitly approved built source version.",
),
steps=(
"Open the campaign overview and choose Schedule.",
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
"Select which configuration domains may be copied and create the schedule.",
"Review each generated draft independently before validating, building, approving, and sending it.",
"Pause the schedule when the approved plan changes; a failed occurrence is paused automatically and remains visible as evidence.",
"Choose manual draft preparation or autonomous approved delivery; mode cannot be changed in place.",
"For manual mode, select which configuration domains may be copied and review each generated draft independently.",
"For autonomous mode, confirm that the selected version is built, Mail-only, and explicitly approved; the API rejects missing or stale evidence.",
"Review next occurrence, last outcome, and recovery state. Resolve uncertain or failed Mail commands explicitly before creating or resuming a replacement schedule.",
),
outcome="A bounded sequence of independent campaign drafts with durable schedule and occurrence evidence.",
verification="The Schedules section shows the next occurrence and generated count; each prepared occurrence links to a distinct draft with no delivery jobs or outcomes.",
outcome="A bounded sequence of manual drafts or at-most-once autonomous Mail commands with durable occurrence and recovery evidence.",
verification="The Schedules section shows mode, next occurrence, last outcome, recovery state, and any automatic pause; manual occurrences link to distinct drafts while autonomous occurrences retain Mail command identifiers and non-secret outcome totals.",
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
translations={
"de": {
"title": "Begrenzte wiederkehrende Kampagnenentwürfe planen",
"summary": "Künftige Kampagnenentwürfe vorbereiten, ohne Validierung, Prüfung, Freigabe oder Versandkontrollen zu umgehen.",
"body": "Ein Kampagnenzeitplan speichert einen integritätsgesicherten Stand der ausgewählten Version, Kampagnenrichtlinie, Mail-Profilrichtlinie und optionalen Freigaben sowie eine begrenzte einmalige, tägliche, wöchentliche oder monatliche Wiederholung. Jede fällige Ausführung erzeugt einen eigenständigen Entwurf und einen Ausführungsnachweis. Verpasste Intervalle werden zusammengefasst, statt unkontrolliert nachgeholt zu werden. Der Zeitplan validiert, genehmigt, startet, wiederholt oder versendet niemals eine Kampagne; ein Fehler pausiert ihn zur betrieblichen Prüfung. Pausieren und Fortsetzen weisen veraltete Browserstände zurück.",
"outcome": "Eine begrenzte Folge eigenständiger Kampagnenentwürfe mit dauerhaftem Zeitplan- und Ausführungsnachweis.",
"verification": "Der Abschnitt Zeitpläne zeigt die nächste Ausführung und die Zahl erzeugter Entwürfe; jede Ausführung verweist auf einen eigenen Entwurf ohne Versandaufträge oder Ergebnisse.",
"title": "Begrenzte manuelle oder autonome Kampagnen planen",
"summary": "Neue Entwürfe vorbereiten oder den Versand eines exakt freigegebenen Builds ausdrücklich autonom ausführen.",
"body": "Jeder Zeitplan ist begrenzt, zeitzonenfest und dauerhaft manuell oder autonom. Der manuelle Modus erzeugt eigenständige Entwürfe und funktioniert ohne Mail. Der autonome Modus verlangt eine gebaute, ausschließlich per Mail versendete und ausdrücklich freigegebene Quellversion. Vor jeder Ausführung werden Freigabe, Richtlinie, Zugangsdatenauswahl, Transportrevision und -erreichbarkeit, Empfänger, Anlagen und Snapshot-Integrität erneut geprüft. Pro eingefrorener Nachricht entsteht vor dem Versand ein dauerhafter Mail-Auftrag mit ausführungsspezifischem Idempotenzschlüssel. Angenommene oder unklare Ergebnisse werden nie automatisch wiederholt; unklare oder systemische Fehler pausieren den Zeitplan und benachrichtigen Verantwortliche. Verpasste Intervalle werden zusammengefasst und als Nachweis erhalten.",
"outcome": "Eine begrenzte Folge manueller Entwürfe oder höchstens einmal angenommener autonomer Mail-Aufträge mit dauerhaftem Wiederherstellungsnachweis.",
"verification": "Der Abschnitt Zeitpläne zeigt Modus, nächste Ausführung, letztes Ergebnis, Wiederherstellungsstatus und automatische Pausen.",
}
},
),
File diff suppressed because it is too large Load Diff
+149 -4
View File
@@ -36,6 +36,7 @@ from govoplan_core.core.modules import (
NavItem,
PermissionDefinition,
ProductAreaContribution,
QuickAccessTool,
RoleTemplate,
)
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
@@ -65,11 +66,19 @@ from govoplan_campaign.backend.documentation import (
CAMPAIGN_USER_DOCUMENTATION,
documentation_topics,
)
from govoplan_campaign.backend.dsar_provider import CAMPAIGN_DSAR_CAPABILITY
from govoplan_campaign.backend.search_source import create_campaign_search_source
register_campaign_change_tracking()
def _dsar_provider(context: ModuleContext) -> object:
del context
from govoplan_campaign.backend.dsar_provider import CampaignDsarProvider
return CampaignDsarProvider()
def _permission(
scope: str, label: str, description: str, category: str
) -> PermissionDefinition:
@@ -113,8 +122,8 @@ PERMISSIONS = (
),
_permission(
"campaigns:campaign:schedule",
"Schedule campaign drafts",
"Prepare fresh campaign drafts at a governed time or bounded recurrence.",
"Schedule campaigns",
"Prepare manual drafts or opt in to approved autonomous Mail delivery at a governed time or bounded recurrence.",
"Campaigns",
),
_permission(
@@ -376,7 +385,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.18",
version="0.1.19",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -401,7 +410,7 @@ manifest = ModuleManifest(
provides_interfaces=(
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.schedules", version="0.1.0"),
ModuleInterfaceProvider(name="campaigns.schedules", version="0.2.0"),
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
@@ -409,6 +418,7 @@ manifest = ModuleManifest(
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
version="1.0.0",
),
ModuleInterfaceProvider(name=CAMPAIGN_DSAR_CAPABILITY, version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
@@ -608,6 +618,32 @@ manifest = ModuleManifest(
label="Campaign activity widget",
order=50,
),
ViewSurface(
id="campaigns.quick_access.campaigns",
module_id="campaigns",
kind="quick_access",
label="Campaign selection",
order=55,
),
),
quick_access_tools=(
QuickAccessTool(
id="campaigns.select",
module_id="campaigns",
category_id="campaigns",
label="Campaigns",
description="Select an authorized exact Campaign for the active Case.",
surface_id="campaigns.quick_access.campaigns",
icon="campaign",
full_page_path="/campaigns",
required_any=("campaigns:campaign:read",),
order=20,
modes=("select",),
availability="active_object",
accepted_reference_kinds=("cases.case",),
returned_reference_kinds=("campaigns.campaign",),
help_context_id="campaigns.quick_access.campaigns",
),
),
),
migration_spec=MigrationSpec(
@@ -659,6 +695,107 @@ manifest = ModuleManifest(
),
documentation=(
*CAMPAIGN_USER_DOCUMENTATION,
DocumentationTopic(
id="campaigns.workflow.link-exact-campaign-to-case",
title="Link an exact Campaign reference to an active Case",
summary=(
"Return an authorized Campaign and current immutable version through "
"Quick Access without copying campaign content."
),
body=(
"When a Case is the active object, Campaigns contributes a bounded "
"Quick Access selector. The normal Campaign list endpoint applies the "
"current actor's tenant, owner, group, share, and administration access "
"before candidates appear. Selecting a Campaign returns only its owner "
"module, stable Campaign ID, current version ID, display label, tenant, "
"and owner route through the versioned result contract. Cases discards "
"the label and stores no recipient, message, attachment, delivery, report, "
"or campaign configuration content. Opening the reference enters Campaigns "
"and rechecks current access. Campaigns disabled, access revoked, or the "
"source removed therefore leaves only an unavailable historical Case "
"reference; it never turns Case access into Campaign access."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("campaign_manager", "case_manager", "operator", "module_admin"),
related_modules=("cases", "quick_access"),
conditions=(
DocumentationCondition(
required_modules=("campaigns", "cases"),
required_scopes=("campaigns:campaign:read",),
),
),
links=(
DocumentationLink(
label="Campaigns",
href="/campaigns",
kind="runtime",
),
),
metadata={
"kind": "workflow",
"help_contexts": ["campaigns.quick_access.campaigns"],
},
),
DocumentationTopic(
id="campaigns.privacy.data-subject-requests",
title="Review Campaign data in a data-subject request",
summary="Collect recipient, version, delivery, report, and artifact metadata without rewriting immutable evidence.",
body=(
"Campaign's DSAR provider searches the effective tenant by normalized recipient email, direct membership references, and namespaced Campaign job, entry, version, or Campaign references. "
"It isolates matching inline-recipient fields and job metadata, and reports built versions, delivery attempts, Postbox and print outcomes, message-action corrections, recipient-specific report projections, generated-message digests, and attachment metadata. It does not export EML bytes, object or local paths, provider target snapshots, worker claims, idempotency material, secrets, credentials, or unrelated recipient addresses. "
"Built, locked, published, terminal, delivered, or corrected records remain retained with a reason and continue through Campaign's configured retention/redaction process. Draft recipient content and user-owned attachment content require coordinated manual review because the same data may occur in version JSON, jobs, and generated artifacts. The provider can idempotently delete a personal recipient-import mapping profile and revoke an active Campaign share aimed at the subject. It never rewrites delivered evidence or deletes generated artifacts directly. Campaign reports are derived projections rather than a separate personal-data store."
),
layer="configured",
documentation_types=("admin",),
audience=("privacy_officer", "campaign_manager", "records_manager", "operator"),
order=42,
conditions=(
DocumentationCondition(
required_modules=("campaigns", "access"),
any_scopes=(
"access:privacy:read",
"access:privacy:manage",
"access:privacy:erase",
),
),
),
links=(
DocumentationLink(
label="Data-subject requests",
href="/admin?section=tenant-data-subject-requests",
kind="runtime",
),
DocumentationLink(
label="Campaign handbook",
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
kind="repository",
),
),
related_modules=("access", "audit", "files", "mail", "postbox", "reporting"),
metadata={
"kind": "workflow",
"route": "/admin?section=tenant-data-subject-requests",
"screen": "Data-subject requests",
"help_contexts": ["admin.privacy.data-subject-requests"],
"prerequisites": [
"The privacy request and recipient selectors have been independently authorized and corroborated.",
"The reviewer understands the effective Campaign retention policy and delivery-evidence obligations.",
],
"steps": [
"Run the Campaign provider search and review recipient, version, job, attempt, report-projection, and artifact dispositions.",
"Inspect matching draft content manually and keep every evidence retention reason with the case decision.",
"Execute only an approved user-owned mapping deletion or subject-targeted share revocation.",
"Use Campaign retention and artifact reconciliation for approved content redaction or expiry; do not mutate delivered evidence ad hoc.",
],
"limitations": [
"Generated EML bytes and attachment content are not embedded in the JSON export; authorized Campaign or Files review paths remain authoritative.",
"Draft recipient erasure is manual until a coordinated version/job/artifact rewrite contract can prove that no partial copy remains.",
],
"outcome": "Campaign personal data receives an explicit retained, review, revoke, or delete disposition without weakening delivery evidence.",
"verification": "Confirm matching recipients are isolated, no locator or credential material appears, report counts derive from the same matched jobs, and repeated reversible actions are unchanged.",
},
),
DocumentationTopic(
id="campaigns.access.child-evidence",
title="Explain access to Campaign child evidence",
@@ -1313,6 +1450,7 @@ manifest = ModuleManifest(
"govoplan_campaign.backend.reports.provider",
fromlist=["CampaignAggregateReportProvider"],
).CampaignAggregateReportProvider(),
CAMPAIGN_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
@@ -1325,6 +1463,13 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"),
audience=("user", "reporting_analyst", "privacy_officer"),
),
CAMPAIGN_DSAR_CAPABILITY: CapabilityDocumentation(
label="Campaign data-subject request provider",
summary="Finds isolated recipient and Campaign evidence metadata and classifies governed erasure actions.",
contract_version="0.1.0",
documentation_types=("admin",),
audience=("privacy_officer", "campaign_manager", "records_manager"),
),
},
operational_check_providers=(
OperationalCheckProviderRegistration(
@@ -0,0 +1,100 @@
"""add governed autonomous Campaign schedule evidence
revision = "b6c7d8e9f0a1"
down_revision = "a5b6c7d8e9f0"
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "b6c7d8e9f0a1"
down_revision = "a5b6c7d8e9f0"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"campaign_schedules",
sa.Column("delivery_mode", sa.String(length=20), nullable=False, server_default="manual"),
)
op.create_index(
"ix_campaign_schedules_delivery_mode",
"campaign_schedules",
["delivery_mode"],
)
op.add_column(
"campaign_schedules",
sa.Column("approved_execution_snapshot_hash", sa.String(length=64), nullable=True),
)
op.create_index(
"ix_campaign_schedules_approved_execution_snapshot_hash",
"campaign_schedules",
["approved_execution_snapshot_hash"],
)
op.add_column(
"campaign_schedules",
sa.Column("last_outcome", sa.String(length=30), nullable=True),
)
op.add_column(
"campaign_schedules",
sa.Column("last_recovery_state", sa.String(length=30), nullable=True),
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("idempotency_key", sa.String(length=200), nullable=True),
)
op.create_index(
"ix_campaign_schedule_occurrences_idempotency_key",
"campaign_schedule_occurrences",
["idempotency_key"],
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("delivery_command_ids", sa.JSON(), nullable=False, server_default="[]"),
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("recovery_state", sa.String(length=30), nullable=False, server_default="none"),
)
op.create_index(
"ix_campaign_schedule_occurrences_recovery_state",
"campaign_schedule_occurrences",
["recovery_state"],
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("evidence", sa.JSON(), nullable=False, server_default="{}"),
)
op.add_column(
"campaign_schedule_occurrences",
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("campaign_schedule_occurrences", "last_checked_at")
op.drop_column("campaign_schedule_occurrences", "evidence")
op.drop_index(
"ix_campaign_schedule_occurrences_recovery_state",
table_name="campaign_schedule_occurrences",
)
op.drop_column("campaign_schedule_occurrences", "recovery_state")
op.drop_column("campaign_schedule_occurrences", "delivery_command_ids")
op.drop_index(
"ix_campaign_schedule_occurrences_idempotency_key",
table_name="campaign_schedule_occurrences",
)
op.drop_column("campaign_schedule_occurrences", "idempotency_key")
op.drop_column("campaign_schedules", "last_recovery_state")
op.drop_column("campaign_schedules", "last_outcome")
op.drop_index(
"ix_campaign_schedules_approved_execution_snapshot_hash",
table_name="campaign_schedules",
)
op.drop_column("campaign_schedules", "approved_execution_snapshot_hash")
op.drop_index("ix_campaign_schedules_delivery_mode", table_name="campaign_schedules")
op.drop_column("campaign_schedules", "delivery_mode")
+22 -1
View File
@@ -31,7 +31,13 @@ from govoplan_core.core.object_storage import (
from govoplan_core.core.runtime_coordination import process_runtime_identity
from govoplan_core.db.session import get_database
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
from govoplan_campaign.backend.db.models import (
CampaignJob,
CampaignSchedule,
CampaignVersion,
JobImapStatus,
JobQueueStatus,
)
from govoplan_campaign.backend.runtime import get_settings
FINAL_VERSION_STATES = {
@@ -351,6 +357,18 @@ def _apply_eml_retention(
"delete_failed": 0,
"recovery_blocked": 0,
"skipped_not_final": 0,
"skipped_schedule_source": 0,
}
protected_source_versions = {
str(version_id)
for (version_id,) in (
session.query(CampaignSchedule.source_version_id)
.filter(
CampaignSchedule.delivery_mode == "autonomous",
CampaignSchedule.next_fire_at.is_not(None),
)
.all()
)
}
jobs = (
session.query(CampaignJob)
@@ -359,6 +377,9 @@ def _apply_eml_retention(
.all()
)
for job in jobs:
if getattr(job, "campaign_version_id", None) in protected_source_versions:
result["skipped_schedule_source"] += 1
continue
policy = policy_for_campaign_id(job.campaign_id)
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
if not _is_before_cutoff(job.updated_at, cutoff):
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.scheduling import (
campaign_schedule_source_snapshot,
canonical_configuration_hash,
validate_autonomous_schedule_source,
)
from govoplan_campaign.backend.db.models import (
CampaignSchedule,
@@ -80,6 +81,10 @@ def create_campaign_schedule(
_require_permission(principal, "campaigns:recipient:read")
if payload.include_shares:
_require_permission(principal, "campaigns:campaign:share")
if payload.delivery_mode == "autonomous":
_require_permission(principal, "campaigns:campaign:queue")
_require_permission(principal, "campaigns:campaign:send")
_require_permission(principal, "mail:profile:use")
source_version = (
session.query(CampaignVersion)
.filter(
@@ -129,12 +134,27 @@ def create_campaign_schedule(
for item in source_shares
],
)
autonomous_evidence: dict[str, object] | None = None
if payload.delivery_mode == "autonomous":
try:
autonomous_evidence = validate_autonomous_schedule_source(
session,
campaign=campaign,
version=source_version,
)
except (RuntimeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
) from exc
snapshot["autonomous_delivery"] = autonomous_evidence
schedule = CampaignSchedule(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
source_version_id=source_version.id,
created_by_user_id=principal.user.id,
name=payload.name.strip(),
delivery_mode=payload.delivery_mode,
recurrence_kind=payload.recurrence_kind,
interval_count=payload.interval_count,
timezone=payload.timezone,
@@ -151,6 +171,11 @@ def create_campaign_schedule(
},
source_snapshot=snapshot,
source_snapshot_hash=canonical_configuration_hash(snapshot),
approved_execution_snapshot_hash=(
str(autonomous_evidence["execution_snapshot_hash"])
if autonomous_evidence is not None
else None
),
source_base_path=source_version.source_base_path,
)
session.add(schedule)
@@ -169,7 +194,19 @@ def create_campaign_schedule(
"starts_at": schedule.starts_at.isoformat(),
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
"max_occurrences": schedule.max_occurrences,
"delivery_mode": schedule.delivery_mode,
"delivery_started": False,
"autonomous_delivery_opted_in": (
schedule.delivery_mode == "autonomous"
),
"approved_execution_snapshot_hash": (
schedule.approved_execution_snapshot_hash
),
"approval_request_id": (
autonomous_evidence.get("approval_request_id")
if autonomous_evidence is not None
else None
),
},
commit=True,
)
@@ -206,6 +243,23 @@ def set_campaign_schedule_state(
status_code=status.HTTP_409_CONFLICT,
detail="A completed campaign schedule cannot be resumed.",
)
if payload.active:
unresolved = (
session.query(CampaignScheduleOccurrence.id)
.filter(
CampaignScheduleOccurrence.schedule_id == schedule.id,
CampaignScheduleOccurrence.status == "uncertain",
)
.first()
)
if unresolved is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"Reconcile the autonomous delivery outcome in Mail before "
"resuming this schedule."
),
)
schedule.active = payload.active
schedule.last_error = None if payload.active else schedule.last_error
schedule.resource_revision += 1
+9
View File
@@ -64,6 +64,7 @@ class CampaignScheduleCreateRequest(BaseModel):
source_version_id: str = Field(min_length=1, max_length=36)
name: str = Field(min_length=1, max_length=255)
delivery_mode: Literal["manual", "autonomous"] = "manual"
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
interval_count: int = Field(default=1, ge=1, le=365)
timezone: str = Field(default="UTC", min_length=1, max_length=100)
@@ -107,9 +108,14 @@ class CampaignScheduleOccurrenceResponse(BaseModel):
schedule_id: str
scheduled_for: datetime
status: str
idempotency_key: str | None = None
generated_campaign_id: str | None = None
generated_version_id: str | None = None
error: str | None = None
delivery_command_ids: list[str] = Field(default_factory=list)
recovery_state: str = "none"
evidence: dict[str, object] = Field(default_factory=dict)
last_checked_at: datetime | None = None
created_at: datetime
@@ -120,6 +126,7 @@ class CampaignScheduleResponse(BaseModel):
campaign_id: str
source_version_id: str
name: str
delivery_mode: str
recurrence_kind: str
interval_count: int
timezone: str
@@ -133,6 +140,8 @@ class CampaignScheduleResponse(BaseModel):
last_fired_at: datetime | None = None
last_campaign_id: str | None = None
last_error: str | None = None
last_outcome: str | None = None
last_recovery_state: str | None = None
created_at: datetime
updated_at: datetime
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
+291 -1
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from contextlib import nullcontext
from datetime import UTC, datetime
from unittest.mock import patch
from types import SimpleNamespace
from unittest.mock import Mock, patch
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -15,6 +17,7 @@ from govoplan_campaign.backend.campaign.scheduling import (
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
@@ -71,6 +74,7 @@ class TestCampaignScheduling:
groups,
Campaign.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
CampaignShare.__table__,
CampaignSchedule.__table__,
CampaignScheduleOccurrence.__table__,
@@ -238,3 +242,289 @@ class TestCampaignScheduling:
assert generated.settings == {"retention": "sealed"}
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
assert schedule.resource_revision == 2
def test_autonomous_occurrences_allocate_commands_once_and_complete_bound(self):
context = SimpleNamespace(
snapshot=SimpleNamespace(
mail_profile_id="profile-1",
smtp_transport_revision="transport-1",
smtp_server_id="smtp-1",
smtp_credential_id="credential-1",
),
message_bytes=b"From: Sender <sender@example.test>\r\nTo: one@example.test\r\n\r\nHello",
envelope_from="sender@example.test",
envelope_recipients=["one@example.test"],
)
job = SimpleNamespace(
id="job-1",
resolved_recipients={"from": {"email": "sender@example.test"}},
)
mail = Mock()
mail.durable_delivery_available = True
mail.delivery_command_summary.return_value = {
"id": "command-1",
"status": "accepted",
"accepted_count": 1,
"refused_count": 0,
"failure_code": None,
}
mail.submit_delivery_command.side_effect = [
{"id": "command-1", "status": "pending", "duplicate": False},
{"id": "command-2", "status": "pending", "duplicate": False},
]
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.delivery_mode = "autonomous"
schedule.approved_execution_snapshot_hash = "a" * 64
session.commit()
validation = {
"execution_snapshot_hash": "a" * 64,
"approval_request_id": "approval-1",
"approval_subject_digest": "b" * 64,
"job_count": 1,
"job_manifest_sha256": "c" * 64,
}
patches = (
patch(
"govoplan_campaign.backend.campaign.scheduling.validate_autonomous_schedule_source",
return_value=validation,
),
patch(
"govoplan_campaign.backend.campaign.scheduling._autonomous_source_jobs",
return_value=[job],
),
patch(
"govoplan_campaign.backend.campaign.scheduling._send_job_delivery_context",
return_value=context,
),
patch(
"govoplan_campaign.backend.campaign.scheduling._synchronous_smtp_batch_manager",
return_value=nullcontext(None),
),
patch(
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
return_value=mail,
),
patch("govoplan_campaign.backend.campaign.scheduling.audit_event"),
)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
first = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
session.commit()
second = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
)
session.commit()
assert first["autonomous_prepared"] == 1
assert second["autonomous_prepared"] == 1
assert schedule.active is False
occurrences = (
session.query(CampaignScheduleOccurrence)
.order_by(CampaignScheduleOccurrence.scheduled_for)
.all()
)
assert [item.status for item in occurrences] == ["accepted", "prepared"]
assert [item.delivery_command_ids for item in occurrences] == [
["command-1"],
["command-2"],
]
assert len({item.idempotency_key for item in occurrences}) == 2
assert [item.recovery_state for item in occurrences] == [
"complete",
"pending",
]
assert occurrences[0].evidence["source_campaign_id"] == "campaign-1"
assert occurrences[0].evidence["source_version_id"] == "version-1"
assert (
occurrences[0].evidence["source_snapshot_hash"]
== schedule.source_snapshot_hash
)
assert mail.submit_delivery_command.call_count == 2
def test_autonomous_unknown_outcome_pauses_without_resubmission(self):
mail = Mock()
mail.durable_delivery_available = True
mail.delivery_command_summary.return_value = {
"id": "command-1",
"status": "outcome_unknown",
"accepted_count": 0,
"refused_count": 0,
"failure_code": "smtp_outcome_unknown",
}
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.delivery_mode = "autonomous"
occurrence = CampaignScheduleOccurrence(
tenant_id="tenant-1",
schedule_id=schedule.id,
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
status="prepared",
idempotency_key="occurrence-1",
delivery_command_ids=["command-1"],
recovery_state="pending",
)
session.add(occurrence)
session.commit()
with patch(
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
return_value=mail,
), patch(
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
) as notify:
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 6, 9, tzinfo=UTC),
)
session.commit()
assert result["refreshed"]["uncertain"] == 1
assert result["selected"] == 0
assert occurrence.status == "uncertain"
assert occurrence.recovery_state == "operator_required"
assert schedule.active is False
assert schedule.last_outcome == "uncertain"
notify.assert_called_once()
mail.submit_delivery_command.assert_not_called()
def test_autonomous_pending_occurrence_defers_the_next_delivery(self):
mail = Mock()
mail.durable_delivery_available = True
mail.delivery_command_summary.return_value = {
"id": "command-1",
"status": "pending",
"accepted_count": 0,
"refused_count": 0,
"failure_code": None,
}
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.delivery_mode = "autonomous"
session.add(
CampaignScheduleOccurrence(
tenant_id="tenant-1",
schedule_id=schedule.id,
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
status="prepared",
idempotency_key="occurrence-1",
delivery_command_ids=["command-1"],
recovery_state="pending",
)
)
session.commit()
with patch(
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
return_value=mail,
):
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
assert result["refreshed"]["checked"] == 1
assert result["deferred"] == 1
assert result["autonomous_prepared"] == 0
assert schedule.active is True
assert schedule.occurrence_count == 0
mail.submit_delivery_command.assert_not_called()
def test_missing_mail_recovery_capability_pauses_an_open_occurrence(self):
mail = Mock()
mail.durable_delivery_available = False
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
schedule.delivery_mode = "autonomous"
occurrence = CampaignScheduleOccurrence(
tenant_id="tenant-1",
schedule_id=schedule.id,
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
status="prepared",
idempotency_key="occurrence-1",
delivery_command_ids=["command-1"],
recovery_state="pending",
)
session.add(occurrence)
session.commit()
with patch(
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
return_value=mail,
), patch(
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
) as notify:
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
assert result["refreshed"]["uncertain"] == 1
assert occurrence.status == "uncertain"
assert occurrence.evidence["recovery_reason"] == (
"mail_delivery_outbox_unavailable"
)
assert schedule.active is False
notify.assert_called_once()
def test_autonomous_source_requires_an_explicit_approval(self):
with self.SessionLocal() as session, patch(
"govoplan_campaign.backend.campaign.scheduling.campaign_approval_gate",
return_value=None,
):
campaign = session.get(Campaign, "campaign-1")
version = session.get(CampaignVersion, "version-1")
assert campaign is not None and version is not None
from govoplan_campaign.backend.campaign.scheduling import (
validate_autonomous_schedule_source,
)
try:
validate_autonomous_schedule_source(
session,
campaign=campaign,
version=version,
)
except RuntimeError as exc:
assert "explicit Approval request" in str(exc)
else: # pragma: no cover - defensive assertion
raise AssertionError("Autonomous source validation unexpectedly passed")
def test_duplicate_occurrence_recovers_schedule_without_another_effect(self):
with self.SessionLocal() as session:
schedule = session.get(CampaignSchedule, "schedule-1")
assert schedule is not None
recorded = CampaignScheduleOccurrence(
tenant_id="tenant-1",
schedule_id=schedule.id,
scheduled_for=schedule.next_fire_at,
status="prepared",
idempotency_key="existing-key",
recovery_state="pending",
)
session.add(recorded)
session.commit()
with patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
result = dispatch_due_campaign_schedules(
session,
tenant_id="tenant-1",
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
)
session.commit()
assert result["duplicates"] == 1
assert result["failed"] == 0
assert schedule.occurrence_count == 1
assert schedule.next_fire_at == datetime(2026, 8, 8, 8, tzinfo=UTC)
assert session.query(CampaignScheduleOccurrence).count() == 1
+705
View File
@@ -0,0 +1,705 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_campaign.backend.db.models import (
AttachmentBlob,
AttachmentInstance,
Campaign,
CampaignIssue,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignSchedule,
CampaignShare,
CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
RecipientImportMappingProfile,
SendAttempt,
)
from govoplan_campaign.backend.dsar_provider import (
CAMPAIGN_DSAR_CAPABILITY,
CampaignDsarProvider,
)
from govoplan_campaign.backend.manifest import manifest
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
execute_data_subject_erasure,
plan_data_subject_erasure,
search_data_subject_request,
)
class _Registry:
def __init__(
self,
provider: CampaignDsarProvider,
*,
campaign_active: bool = True,
) -> None:
self.provider = provider
self.campaign_active = campaign_active
def capability_names(self):
return (CAMPAIGN_DSAR_CAPABILITY,)
def capability_owner(self, name):
self._assert_capability(name)
return "campaigns"
def tenant_entitlement_resolver(self):
campaign_active = self.campaign_active
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("campaigns",) if campaign_active else ()},
)()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
self._assert_capability(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "campaigns"})(),)
@staticmethod
def _assert_capability(name: str) -> None:
if name != CAMPAIGN_DSAR_CAPABILITY:
raise KeyError(name)
class CampaignDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(
bind=self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
ChangeSequenceEntry.__table__,
DataSubjectRequest.__table__,
Campaign.__table__,
CampaignShare.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
CampaignIssue.__table__,
AttachmentBlob.__table__,
AttachmentInstance.__table__,
SendAttempt.__table__,
CampaignMessageAction.__table__,
CampaignMessageActionAttempt.__table__,
ImapAppendAttempt.__table__,
PostboxDeliveryAttempt.__table__,
PrintOutputAttempt.__table__,
RecipientImportMappingProfile.__table__,
CampaignSchedule.__table__,
],
)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
self.account = Account(
id="account-1",
email="subject@example.test",
normalized_email="subject@example.test",
display_name="Subject",
)
other_account = Account(
id="account-2",
email="other@example.test",
normalized_email="other@example.test",
display_name="Other",
)
self.user = User(
id="membership-1",
tenant_id="tenant-1",
account_id=self.account.id,
email="subject@example.test",
display_name="Subject",
)
self.other_user = User(
id="membership-2",
tenant_id="tenant-1",
account_id=other_account.id,
email="other@example.test",
display_name="Other",
)
self.campaign = Campaign(
id="campaign-1",
tenant_id="tenant-1",
created_by_user_id=self.other_user.id,
owner_user_id=self.other_user.id,
external_id="privacy-notice",
name="Privacy notice",
status="active",
)
self.version = CampaignVersion(
id="version-1",
campaign_id=self.campaign.id,
version_number=1,
workflow_state="built",
execution_snapshot_hash="a" * 64,
raw_json={
"entries": {
"inline": [
{
"id": "entry-subject",
"to": [
{
"email": "subject@example.test",
"name": "Subject Person",
}
],
"cc": [
{
"email": "other@example.test",
"name": "Unrelated person",
}
],
"body": "private-rendered-body-do-not-export",
"password": "inline-secret-do-not-export",
"case_reference": "CASE-SUBJECT-1",
},
{
"id": "entry-other",
"to": [{"email": "other@example.test"}],
"private_value": "other-recipient-data-do-not-export",
},
]
}
},
)
self.draft_version = CampaignVersion(
id="version-draft",
campaign_id=self.campaign.id,
version_number=2,
workflow_state="editing",
raw_json={
"entries": {
"inline": [
{
"id": "entry-draft-subject",
"to": [{"email": "subject@example.test"}],
"case_reference": "CASE-DRAFT-1",
}
]
}
},
)
self.job = CampaignJob(
id="job-subject",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
campaign_version_id=self.version.id,
entry_index=0,
entry_id="entry-subject",
recipient_email="Subject@Example.Test",
subject="Your governed notice",
eml_storage_key="private/eml/key-do-not-export",
eml_local_path="/private/message-do-not-export.eml",
eml_size_bytes=512,
eml_sha256="b" * 64,
build_status="built",
validation_status="ready",
queue_status="completed",
send_status="smtp_accepted",
postbox_status="accepted",
print_status="accepted",
imap_status="appended",
attempt_count=1,
queued_at=now,
sent_at=now,
claim_token="job-claim-do-not-export",
resolved_recipients={
"from": {"email": "sender@example.test"},
"to": [{"email": "subject@example.test", "name": "Subject"}],
"cc": [{"email": "other@example.test", "name": "Other"}],
"legacy": ["other@example.test", "subject@example.test"],
},
resolved_attachments=[
{"storage_key": "resolved-attachment-key-do-not-export"}
],
)
other_job = CampaignJob(
id="job-other",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
campaign_version_id=self.version.id,
entry_index=1,
entry_id="entry-other",
recipient_email="other@example.test",
subject="Other person's message",
build_status="built",
validation_status="ready",
)
tenant_two_campaign = Campaign(
id="campaign-tenant-2",
tenant_id="tenant-2",
external_id="other-tenant",
name="Other tenant data do not export",
)
tenant_two_version = CampaignVersion(
id="version-tenant-2",
campaign_id=tenant_two_campaign.id,
version_number=1,
raw_json={
"entries": {"inline": [{"to": [{"email": "subject@example.test"}]}]}
},
)
tenant_two_job = CampaignJob(
id="job-tenant-2",
tenant_id="tenant-2",
campaign_id=tenant_two_campaign.id,
campaign_version_id=tenant_two_version.id,
entry_index=0,
recipient_email="subject@example.test",
)
self.issue = CampaignIssue(
id="issue-1",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
campaign_version_id=self.version.id,
job_id=self.job.id,
severity="warning",
code="delivery_warning",
message="issue-detail-do-not-export",
source="private-source-do-not-export",
behavior="review",
)
send_attempt = SendAttempt(
id="send-attempt-1",
job_id=self.job.id,
attempt_number=1,
status="accepted",
claim_token="attempt-claim-do-not-export",
smtp_status_code=250,
smtp_response="smtp-response-do-not-export",
error_message="transport-detail-do-not-export",
started_at=now,
finished_at=now,
)
postbox_attempt = PostboxDeliveryAttempt(
id="postbox-attempt-1",
tenant_id="tenant-1",
job_id=self.job.id,
target_key="target-key-do-not-export",
target_index=0,
attempt_number=1,
idempotency_key="postbox-idempotency-do-not-export",
status="accepted",
target_snapshot={"private": "snapshot-do-not-export"},
provider_delivery_id="delivery-1",
provider_message_id="message-1",
postbox_id="postbox-1",
address="subject@example.test",
evidence={"private": "postbox-evidence-do-not-export"},
started_at=now,
finished_at=now,
)
print_attempt = PrintOutputAttempt(
id="print-attempt-1",
tenant_id="tenant-1",
job_id=self.job.id,
attempt_number=1,
idempotency_key="print-idempotency-do-not-export",
status="accepted",
render_id="render-1",
artifact_sha256="c" * 64,
evidence={"private": "print-evidence-do-not-export"},
started_at=now,
finished_at=now,
)
self.share = CampaignShare(
id="share-1",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
target_type="user",
target_id=self.user.id,
permission="read",
created_by_user_id=self.other_user.id,
)
self.profile = RecipientImportMappingProfile(
id="mapping-1",
tenant_id="tenant-1",
owner_user_id=self.user.id,
name="Subject mapping",
column_count=2,
headers=["email", "case_reference"],
normalized_headers=["email", "case_reference"],
ordered_header_fingerprint="d" * 64,
unordered_header_fingerprint="e" * 64,
delimiter=";",
header_rows=1,
quoted=True,
value_separators=",;|",
mappings=[
{"header": "email", "field": "to.0.email"},
{"secret": "profile-secret-do-not-export"},
],
)
blob = AttachmentBlob(
id="blob-1",
tenant_id="tenant-1",
sha256="f" * 64,
size_bytes=42,
mime_type="application/pdf",
storage_bucket="private-bucket-do-not-export",
storage_key="private-attachment-key-do-not-export",
)
attachment = AttachmentInstance(
id="attachment-1",
tenant_id="tenant-1",
owner_user_id=self.other_user.id,
campaign_id=self.campaign.id,
blob_id=blob.id,
logical_name="notice",
filename="notice.pdf",
tags=["notice"],
metadata_={"secret": "attachment-secret-do-not-export"},
)
schedule = CampaignSchedule(
id="schedule-1",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
source_version_id=self.version.id,
created_by_user_id=self.other_user.id,
name="Recurring privacy notice",
delivery_mode="manual",
recurrence_kind="monthly",
starts_at=now,
next_fire_at=now,
max_occurrences=12,
source_snapshot={"private": "schedule-snapshot-do-not-export"},
source_snapshot_hash="1" * 64,
)
self.session.add_all(
[
self.account,
other_account,
self.user,
self.other_user,
self.campaign,
self.version,
self.draft_version,
self.job,
other_job,
tenant_two_campaign,
tenant_two_version,
tenant_two_job,
self.issue,
send_attempt,
postbox_attempt,
print_attempt,
self.share,
self.profile,
blob,
attachment,
schedule,
]
)
self.session.commit()
self.provider = CampaignDsarProvider()
self.subject = DsarSubjectRef(
membership_id=self.user.id,
email="subject@example.test",
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
provided_names = {item.name for item in manifest.provides_interfaces}
self.assertIn(CAMPAIGN_DSAR_CAPABILITY, provided_names)
provider = manifest.capability_factories[CAMPAIGN_DSAR_CAPABILITY](None)
self.assertIsInstance(provider, DsarProvider)
self.assertIn(
"campaigns.privacy.data-subject-requests",
{topic.id for topic in manifest.documentation},
)
def test_search_is_tenant_scoped_minimized_and_recipient_specific(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
resource_types = {record.resource_type for record in records}
self.assertTrue(
{
"campaign",
"campaign_version",
"campaign_recipient_job",
"campaign_message_artifact",
"campaign_delivery_issue",
"campaign_send_attempt",
"campaign_postbox_attempt",
"campaign_print_attempt",
"campaign_report_projection",
"campaign_share",
"recipient_import_mapping_profile",
"campaign_schedule",
"campaign_attachment",
}.issubset(resource_types)
)
report = next(
record
for record in records
if record.resource_type == "campaign_report_projection"
)
self.assertEqual(1, report.data["matched_job_count"])
artifact = next(
record
for record in records
if record.resource_type == "campaign_message_artifact"
)
self.assertEqual("b" * 64, artifact.data["sha256"])
self.assertEqual(512, artifact.data["size_bytes"])
serialized = repr([record.to_dict() for record in records])
for hidden in (
"job-tenant-2",
"Other tenant data do not export",
"job-other",
"other@example.test",
"Unrelated person",
"other-recipient-data-do-not-export",
"private-rendered-body-do-not-export",
"inline-secret-do-not-export",
"private/eml/key-do-not-export",
"/private/message-do-not-export.eml",
"resolved-attachment-key-do-not-export",
"job-claim-do-not-export",
"issue-detail-do-not-export",
"private-source-do-not-export",
"attempt-claim-do-not-export",
"smtp-response-do-not-export",
"transport-detail-do-not-export",
"target-key-do-not-export",
"postbox-idempotency-do-not-export",
"snapshot-do-not-export",
"postbox-evidence-do-not-export",
"print-idempotency-do-not-export",
"print-evidence-do-not-export",
"profile-secret-do-not-export",
"private-bucket-do-not-export",
"private-attachment-key-do-not-export",
"attachment-secret-do-not-export",
"schedule-snapshot-do-not-export",
):
self.assertNotIn(hidden, serialized)
def test_conflicting_email_references_fail_closed_for_recipient_data(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
email="subject@example.test",
external_references={"campaign.email": "other@example.test"},
),
)
self.assertEqual((), records)
def test_plan_retains_evidence_and_limits_execution_to_reversible_data(
self,
) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
kinds = {action.kind for action in actions}
self.assertTrue({"retain", "manual_review", "revoke", "delete"}.issubset(kinds))
self.assertTrue(
any(
action.action_id
== "campaigns:retain:campaign_recipient_job:job-subject"
for action in actions
)
)
self.assertTrue(
any(
action.action_id == "campaigns:review:campaign_version:version-draft"
for action in actions
)
)
executable_ids = {action.action_id for action in actions if action.executable}
self.assertEqual(
{
"campaigns:revoke:campaign_share:share-1",
"campaigns:delete:recipient_import_mapping_profile:mapping-1",
},
executable_ids,
)
def test_execution_is_revalidated_tenant_bound_and_idempotent(self) -> None:
actions = self._executable_actions()
wrong_tenant = self.provider.execute_erasure(
self.session,
tenant_id="tenant-2",
subject=self.subject,
actions=actions,
request_id="dsar-wrong-tenant",
)
self.assertEqual({"blocked"}, {result.status for result in wrong_tenant})
first = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual({"executed"}, {result.status for result in first})
self.session.flush()
self.assertIsNotNone(self.share.revoked_at)
self.assertIsNone(
self.session.get(RecipientImportMappingProfile, self.profile.id)
)
repeated = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=actions,
request_id="dsar-1",
)
self.assertEqual({"unchanged"}, {result.status for result in repeated})
def test_execution_blocks_when_mapping_owner_changed_after_planning(self) -> None:
delete_action = next(
action
for action in self._executable_actions()
if action.resource_type == "recipient_import_mapping_profile"
)
self.profile.owner_user_id = self.other_user.id
self.session.flush()
result = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(delete_action,),
request_id="dsar-stale",
)
self.assertEqual("blocked", result[0].status)
self.assertIsNotNone(
self.session.get(RecipientImportMappingProfile, self.profile.id)
)
def test_core_workflow_discovers_active_provider_and_skips_it_when_disabled(
self,
) -> None:
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-CAMPAIGN-1",
request_kind="access_and_erasure",
subject=self.subject,
purpose="Respond to an authorized privacy request.",
legal_basis="Article 15 and 17 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
self.session.commit()
registry = _Registry(self.provider)
search_data_subject_request(
self.session,
registry=registry,
row=request,
expected_revision=1,
)
self.assertEqual("searched", request.status)
self.assertEqual(["campaigns"], request.coverage["covered_modules"])
self.assertEqual([], request.coverage["modules_without_provider"])
plan_data_subject_erasure(
self.session,
registry=registry,
row=request,
expected_revision=2,
)
executable_ids = [
action["action_id"]
for action in request.erasure_plan["actions"]
if action["executable"]
]
execute_data_subject_erasure(
self.session,
registry=registry,
row=request,
expected_revision=3,
action_ids=executable_ids,
)
self.assertEqual("completed", request.status)
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-CAMPAIGN-DISABLED",
request_kind="access",
subject=self.subject,
purpose="Verify disabled-module coverage.",
legal_basis="Article 15 GDPR",
due_at=None,
requested_by_account_id="privacy-officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, campaign_active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(0, disabled.search_result["record_count"])
self.assertEqual(
[CAMPAIGN_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
def _executable_actions(self):
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
return tuple(action for action in actions if action.executable)
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -73,3 +73,21 @@ def test_reusable_template_library_is_not_owned_by_campaign() -> None:
assert "/templates" not in {item.path for item in manifest.nav_items}
assert "/templates" not in {item.path for item in manifest.frontend.nav_items}
assert "/templates" not in {route.path for route in manifest.frontend.routes}
def test_quick_access_selects_exact_campaigns_only_for_active_cases() -> None:
manifest = get_manifest()
assert manifest.frontend is not None
tool = next(
item
for item in manifest.frontend.quick_access_tools
if item.id == "campaigns.select"
)
assert tool.availability == "active_object"
assert tool.accepted_reference_kinds == ("cases.case",)
assert tool.returned_reference_kinds == ("campaigns.campaign",)
assert tool.modes == ("select",)
assert "campaigns.workflow.link-exact-campaign-to-case" in {
topic.id for topic in manifest.documentation
}
+36
View File
@@ -138,3 +138,39 @@ def test_eml_retention_removes_only_terminal_artifact(tmp_path) -> None:
assert job.eml_local_path is None
assert job.eml_storage_key is None
session.add.assert_called_once_with(job)
def test_eml_retention_preserves_unfinished_autonomous_schedule_source(tmp_path) -> None:
now = datetime.now(timezone.utc)
eml_path = tmp_path / "approved-source.eml"
eml_path.write_bytes(b"approved message")
job = SimpleNamespace(
campaign_id="campaign-1",
campaign_version_id="version-1",
updated_at=now - timedelta(days=10),
queue_status="draft",
send_status="smtp_accepted",
imap_status="appended",
eml_local_path=str(eml_path),
eml_storage_key=None,
)
schedule_query = MagicMock()
schedule_query.filter.return_value.all.return_value = [("version-1",)]
job_query = MagicMock()
job_query.filter.return_value.order_by.return_value.all.return_value = [job]
session = MagicMock()
session.query.side_effect = [schedule_query, job_query]
policy = SimpleNamespace(generated_eml_retention_days=1)
result = _apply_eml_retention(
session,
dry_run=False,
now=now,
policy_for_campaign_id=lambda _campaign_id: policy,
)
assert result["skipped_schedule_source"] == 1
assert result["metadata_cleared"] == 0
assert eml_path.exists()
assert job.eml_local_path == str(eml_path)
session.add.assert_not_called()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.18",
"version": "0.1.19",
"private": true,
"type": "module",
"main": "src/index.ts",
+10 -1
View File
@@ -138,10 +138,15 @@ export type CampaignScheduleOccurrence = {
id: string;
schedule_id: string;
scheduled_for: string;
status: "prepared" | "failed" | string;
status: "prepared" | "accepted" | "uncertain" | "failed" | "skipped" | "superseded" | string;
idempotency_key?: string | null;
generated_campaign_id?: string | null;
generated_version_id?: string | null;
error?: string | null;
delivery_command_ids: string[];
recovery_state: string;
evidence: Record<string, unknown>;
last_checked_at?: string | null;
created_at: string;
};
@@ -150,6 +155,7 @@ export type CampaignSchedule = {
campaign_id: string;
source_version_id: string;
name: string;
delivery_mode: "manual" | "autonomous";
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
interval_count: number;
timezone: string;
@@ -163,6 +169,8 @@ export type CampaignSchedule = {
last_fired_at?: string | null;
last_campaign_id?: string | null;
last_error?: string | null;
last_outcome?: string | null;
last_recovery_state?: string | null;
created_at: string;
updated_at: string;
occurrences: CampaignScheduleOccurrence[];
@@ -171,6 +179,7 @@ export type CampaignSchedule = {
export type CampaignScheduleCreate = {
source_version_id: string;
name: string;
delivery_mode: CampaignSchedule["delivery_mode"];
recurrence_kind: CampaignSchedule["recurrence_kind"];
interval_count: number;
timezone: string;
@@ -75,6 +75,7 @@ function defaultScheduleDraft(): CampaignScheduleCreate {
return {
source_version_id: "",
name: "",
delivery_mode: "manual",
recurrence_kind: "once",
interval_count: 1,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
@@ -113,6 +114,10 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
const canAutonomousSchedule = canSchedule
&& hasScope(auth, "campaigns:campaign:queue")
&& hasScope(auth, "campaigns:campaign:send")
&& hasScope(auth, "mail:profile:use");
function openSection(section: string, fragment = "") {
const params = new URLSearchParams();
@@ -317,7 +322,9 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
});
setSchedules((current) => [created, ...current]);
setScheduleDialogOpen(false);
setMessage("Campaign schedule created. Each occurrence prepares a fresh draft for review; it does not send automatically.");
setMessage(created.delivery_mode === "autonomous"
? "Autonomous schedule created from the exact approved source execution."
: "Manual schedule created. Each occurrence prepares a fresh draft for review.");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -433,17 +440,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<CalendarClock size={16} aria-hidden="true" />
Add schedule
</Button> : undefined}>
<p className="muted small-note">Due occurrences prepare independent campaign drafts. Validation, review, approval, and delivery are never started by the schedule.</p>
<p className="muted small-note">Manual schedules prepare independent drafts. Opt-in autonomous schedules reuse the exact approved build through Mail's durable delivery outbox.</p>
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
<div className="campaign-schedule-main">
<strong>{schedule.name}</strong>
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} prepared</span>
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} occurrences</span>
<span><StatusBadge status={schedule.delivery_mode} /> {schedule.delivery_mode === "autonomous" ? "Approved source delivery" : "Draft preparation"}</span>
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
{schedule.last_outcome && <span>Last outcome: <StatusBadge status={schedule.last_outcome} /> · recovery {schedule.last_recovery_state ?? "none"}</span>}
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
</div>
<div className="button-row compact-actions">
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>Open latest draft</Link>}
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>{schedule.delivery_mode === "autonomous" ? "Open approved source" : "Open latest draft"}</Link>}
{schedule.next_fire_at && <Button
iconOnly
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
@@ -573,7 +582,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<Dialog
open={scheduleDialogOpen}
title="Schedule campaign drafts"
title="Schedule campaign"
className="campaign-schedule-dialog"
helpContextId="campaigns.action.schedule-drafts"
closeDisabled={scheduleBusy}
@@ -584,11 +593,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
</Button>
</>}>
<DismissibleAlert tone="info" resetKey="campaign-schedule-safety">
A schedule creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically.
<DismissibleAlert tone="info" resetKey={`campaign-schedule-safety-${scheduleDraft.delivery_mode}`}>
{scheduleDraft.delivery_mode === "autonomous"
? "Autonomous delivery is opt-in. It requires a built, explicitly approved Mail-only source version and rechecks approval, policy, credentials, transport health, recipients, attachments, and snapshot integrity before each occurrence. Unknown outcomes pause the schedule and are never retried automatically."
: "Manual mode creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically and remains available without Mail."}
</DismissibleAlert>
<div className="campaign-schedule-form">
<FormField label="Draft name">
<FormField label="Execution mode" help="Mode is fixed for this schedule. Create a new schedule when the approved delivery plan changes.">
<select value={scheduleDraft.delivery_mode} onChange={(event) => setScheduleDraft((current) => ({ ...current, delivery_mode: event.target.value as CampaignSchedule["delivery_mode"] }))}>
<option value="manual">Manual draft preparation</option>
<option value="autonomous" disabled={!canAutonomousSchedule}>Autonomous approved delivery</option>
</select>
</FormField>
<FormField label="Schedule name">
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
</FormField>
<FormField label="First occurrence">
@@ -618,13 +635,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
</FormField>
</div>
<div className="campaign-copy-options">
{scheduleDraft.delivery_mode === "manual" && <div className="campaign-copy-options">
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
</div>
</div>}
</Dialog>
<ConfirmDialog
@@ -0,0 +1,74 @@
import { Megaphone, X } from "lucide-react";
import { useCallback } from "react";
import {
Button,
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
useDashboardWidgetData,
type QuickAccessToolRenderContext
} from "@govoplan/core-webui";
import { listCampaigns } from "../../api/campaigns";
type Props = Pick<
QuickAccessToolRenderContext,
"settings" | "launchContext" | "complete" | "cancel"
>;
export default function CampaignQuickAccess({
settings,
launchContext,
complete,
cancel
}: Props) {
const load = useCallback(
async () => (await listCampaigns(settings))
.filter((campaign) => Boolean(campaign.current_version_id))
.slice(0, 12),
[settings]
);
const { data, loading, error } = useDashboardWidgetData(load, 0);
return (
<LoadingFrame loading={loading} label="Loading authorized Campaigns">
<p className="muted small-note">
Select an exact Campaign version for {launchContext.activeObject?.label}.
Campaign content remains in Campaigns and access is checked again when opened.
</p>
{error ? (
<DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>
) : null}
<DashboardWidgetList
emptyText="No authorized versioned Campaign is available."
items={(data ?? []).map((campaign) => ({
id: campaign.id,
title: campaign.name,
detail: campaign.description || campaign.status,
meta: campaign.status,
leading: <Megaphone size={17} aria-hidden="true" />,
onClick: () => complete({
contractVersion: "1",
outcome: "completed",
action: "selected",
reference: {
ownerModule: "campaigns",
kind: "campaign",
objectId: campaign.id,
tenantId: launchContext.tenantId,
label: campaign.name,
version: campaign.current_version_id!,
path: `/campaigns/${encodeURIComponent(campaign.id)}`
}
})
}))}
/>
<div className="dashboard-contribution-footer">
<Button onClick={() => cancel("user")}>
<X size={15} aria-hidden="true" /> Cancel selection
</Button>
</div>
</LoadingFrame>
);
}
+12 -1
View File
@@ -6,10 +6,12 @@ import {
type ApiSettings,
type AuthInfo,
type DashboardWidgetsUiCapability,
type PlatformWebModule
type PlatformWebModule,
type QuickAccessToolsUiCapability
} from "@govoplan/core-webui";
import { getCampaign } from "./api/campaigns";
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
import CampaignQuickAccess from "./features/campaigns/CampaignQuickAccess";
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess";
import { generatedTranslations } from "./i18n/generatedTranslations";
import { campaignWizardDirectories } from "./features/campaigns/wizard/directory";
@@ -72,6 +74,14 @@ const campaignDashboardWidgets: DashboardWidgetsUiCapability = {
}
]
};
const campaignQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [
{
id: "campaigns.select",
render: (context) => createElement(CampaignQuickAccess, context)
}
]
};
export const campaignModule: PlatformWebModule = {
id: "campaigns",
@@ -100,6 +110,7 @@ export const campaignModule: PlatformWebModule = {
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
uiCapabilities: {
"dashboard.widgets": campaignDashboardWidgets,
"quickAccess.tools": campaignQuickAccessTools,
"wizard.directories": campaignWizardDirectories
}
@@ -21,6 +21,13 @@ assert.match(overview, /include_files/);
assert.match(overview, /include_shares/);
assert.match(overview, /include_policies/);
assert.match(overview, /include_mail_profile/);
assert.match(overview, /delivery_mode: "manual"/);
assert.match(overview, /Autonomous approved delivery/);
assert.match(overview, /campaigns:campaign:send/);
assert.match(overview, /mail:profile:use/);
assert.match(overview, /schedule\.last_outcome/);
assert.match(overview, /schedule\.last_recovery_state/);
assert.match(overview, /Unknown outcomes pause the schedule and are never retried automatically/);
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);