feat: harden campaign delivery and editing

This commit is contained in:
2026-07-30 14:27:04 +02:00
parent 4a120e8009
commit cd223cbb95
34 changed files with 3370 additions and 165 deletions
+679 -42
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import json
from collections import Counter
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from email import policy
@@ -11,13 +12,18 @@ from typing import Any
from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
from govoplan_core.audit.logging import audit_event
from govoplan_core.security.redaction import redact_secret_values
from govoplan_core.settings import settings as core_settings
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignStatus,
CampaignVersion,
CampaignVersionWorkflowState,
@@ -1305,49 +1311,139 @@ def send_single_campaign_job(
tenant_id: str,
campaign_id: str,
job_id: str,
kind: str,
idempotency_key: str,
actor_user_id: str | None,
actor_api_key_id: str | None = None,
reason: str | None = None,
action_context: dict[str, Any] | None = None,
include_warnings: bool = True,
dry_run: bool = False,
use_rate_limit: bool = True,
enqueue_imap_task: bool = False,
) -> dict[str, Any]:
"""Explicitly queue and send one built recipient message through the audit send path."""
"""Perform one explicit, idempotent action for one immutable built message."""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
job = session.get(CampaignJob, job_id)
if not job or job.tenant_id != tenant_id or job.campaign_id != campaign.id:
raise QueueingError("Campaign job not found or not accessible")
version = _get_current_version(session, campaign, version_id=job.campaign_version_id)
_ensure_version_validated_and_locked(version)
ensure_execution_snapshot(session, version)
queue_action = _prepare_single_job_for_send(
normalized_kind = str(kind or "").strip()
if normalized_kind not in {"test", "single_send", "single_resend"}:
raise QueueingError(
"Single-message action kind must be test, single_send, or single_resend"
)
clean_key = str(idempotency_key or "").strip()
if not clean_key or len(clean_key) > 200:
raise QueueingError("A bounded idempotency key is required")
clean_reason = " ".join(str(reason or "").split()) or None
if normalized_kind == "single_resend" and not clean_reason:
raise QueueingError("Single-message resend requires a reason")
if clean_reason and len(clean_reason) > 2000:
raise QueueingError("Single-message action reason is too long")
version = _get_version_for_campaign(
session,
campaign,
version_id=job.campaign_version_id,
)
if normalized_kind == "single_send" and campaign.current_version_id != version.id:
raise QueueingError(
"An initial official send is only available for the current campaign version."
)
recipients = _recipients_from_job(job)
recipient_manifest_sha256 = hashlib.sha256(
json.dumps(
recipients,
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
canonical_request_hash = hashlib.sha256(
json.dumps(
{
"tenant_id": tenant_id,
"campaign_id": campaign.id,
"version_id": version.id,
"job_id": job.id,
"kind": normalized_kind,
"message_sha256": job.eml_sha256,
"recipient_manifest_sha256": recipient_manifest_sha256,
"reason": clean_reason,
"context": _bounded_single_action_context(action_context),
},
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
).hexdigest()
action, duplicate = _create_single_message_action(
session,
tenant_id=tenant_id,
campaign=campaign,
version=version,
job=job,
include_warnings=include_warnings,
dry_run=dry_run,
kind=normalized_kind,
idempotency_key=clean_key,
canonical_request_hash=canonical_request_hash,
reason=clean_reason,
action_context=_bounded_single_action_context(action_context),
actor_user_id=actor_user_id,
actor_api_key_id=actor_api_key_id,
recipient_manifest_sha256=recipient_manifest_sha256,
recipient_count=len(recipients),
)
if dry_run:
context = _send_job_delivery_context(session, job)
return {
"campaign_id": campaign.id,
"version_id": version.id,
"job_id": job.id,
"action": "single_send",
"queue_action": queue_action,
"dry_run": True,
"result": SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=(
f"Would deliver via {job.delivery_channel_policy}: "
f"{len(context.envelope_recipients)} Mail recipient(s), "
f"{len(job.resolved_postbox_targets or [])} Postbox target(s)"
),
).as_dict(),
}
if duplicate:
return _single_message_action_response(action, duplicate=True)
try:
_validate_single_message_action(
version=version,
job=job,
kind=normalized_kind,
include_warnings=include_warnings,
)
delivery_context = _send_job_delivery_context(session, job)
except Exception as exc:
_finish_single_message_action(
session,
action=action,
status="initiation_failed",
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
raise
if normalized_kind in {"test", "single_resend"}:
return _send_single_message_direct(
session,
campaign=campaign,
version=version,
job=job,
action=action,
delivery_context=delivery_context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
try:
queue_action = _prepare_single_job_for_send(
session,
version=version,
job=job,
include_warnings=include_warnings,
dry_run=False,
)
except Exception as exc:
_finish_single_message_action(
session,
action=action,
status="initiation_failed",
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
raise
if queue_action == "queued":
previous_status = campaign.status
campaign.status = CampaignStatus.QUEUED.value
@@ -1365,24 +1461,565 @@ def send_single_campaign_job(
version_id=version.id,
)
result = send_campaign_job(
session,
job_id=job.id,
dry_run=False,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
action_attempt = _start_single_message_action_attempt(session, action)
try:
result = send_campaign_job(
session,
job_id=job.id,
dry_run=False,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
except Exception as exc:
latest = (
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.desc())
.first()
)
if latest is not None and latest.started_at is not None:
action.linked_send_attempt_id = latest.id
status = _single_action_status_from_job(job)
_finish_single_message_action(
session,
action=action,
attempt=action_attempt,
status=status,
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
raise
latest = (
session.query(SendAttempt)
.filter(SendAttempt.job_id == job.id)
.order_by(SendAttempt.attempt_number.desc())
.first()
)
if latest is not None:
action.linked_send_attempt_id = latest.id
status = _single_action_status_from_result(result.status)
_finish_single_message_action(
session,
action=action,
attempt=action_attempt,
status=status,
final_send_status=job.send_status,
)
response = _single_message_action_response(action)
response["queue_action"] = queue_action
response["result"] = result.as_dict()
return response
def _bounded_single_action_context(
value: dict[str, Any] | None,
) -> dict[str, Any]:
if not isinstance(value, dict):
return {}
result: dict[str, Any] = {}
for raw_key, raw_value in list(value.items())[:20]:
key = str(raw_key).strip()[:80]
if not key:
continue
if isinstance(raw_value, (str, int, float, bool)) or raw_value is None:
result[key] = (
str(raw_value)[:500]
if isinstance(raw_value, str)
else raw_value
)
redacted = redact_secret_values(result)
return redacted if isinstance(redacted, dict) else {}
def _create_single_message_action(
session: Session,
*,
tenant_id: str,
campaign: Campaign,
version: CampaignVersion,
job: CampaignJob,
kind: str,
idempotency_key: str,
canonical_request_hash: str,
reason: str | None,
action_context: dict[str, Any],
actor_user_id: str | None,
actor_api_key_id: str | None,
recipient_manifest_sha256: str,
recipient_count: int,
) -> tuple[CampaignMessageAction, bool]:
existing = (
session.query(CampaignMessageAction)
.filter(
CampaignMessageAction.tenant_id == tenant_id,
CampaignMessageAction.idempotency_key == idempotency_key,
)
.first()
)
if existing is not None:
if existing.canonical_request_hash != canonical_request_hash:
raise QueueingError(
"The idempotency key is already bound to another single-message action"
)
_freeze_replayed_in_progress_action(session, existing)
return existing, True
action = CampaignMessageAction(
tenant_id=tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
job_id=job.id,
kind=kind,
idempotency_key=idempotency_key,
canonical_request_hash=canonical_request_hash,
reason=reason,
context=action_context,
actor_user_id=actor_user_id,
actor_api_key_id=actor_api_key_id,
message_sha256=str(job.eml_sha256 or ""),
message_size_bytes=job.eml_size_bytes,
recipient_manifest_sha256=recipient_manifest_sha256,
recipient_count=recipient_count,
prior_send_status=job.send_status,
prior_attempt_count=job.attempt_count,
status="initiated",
)
try:
with session.begin_nested():
session.add(action)
session.flush()
except IntegrityError:
existing = (
session.query(CampaignMessageAction)
.filter(
CampaignMessageAction.tenant_id == tenant_id,
CampaignMessageAction.idempotency_key == idempotency_key,
)
.first()
)
if (
existing is None
or existing.canonical_request_hash != canonical_request_hash
):
raise QueueingError(
"The idempotency key is already bound to another single-message action"
) from None
_freeze_replayed_in_progress_action(session, existing)
return existing, True
session.commit()
audit_event(
session,
tenant_id=tenant_id,
user_id=actor_user_id,
api_key_id=actor_api_key_id,
action="campaign.message_action_initiated",
object_type="campaign_message_action",
object_id=action.id,
details={
"campaign_id": campaign.id,
"campaign_version_id": version.id,
"job_id": job.id,
"kind": kind,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": recipient_manifest_sha256,
"recipient_count": recipient_count,
"prior_send_status": job.send_status,
"reason": reason,
},
)
session.commit()
return action, False
def _freeze_replayed_in_progress_action(
session: Session,
action: CampaignMessageAction,
) -> None:
if action.status != "effect_in_progress":
return
now = _utcnow()
action.status = "outcome_unknown"
action.completed_at = now
action.error_type = "OutcomeUnknown"
action.error_message = (
"A repeated command found an unfinished provider effect. "
"Automatic resend was stopped."
)
attempt = (
session.query(CampaignMessageActionAttempt)
.filter(CampaignMessageActionAttempt.action_id == action.id)
.order_by(CampaignMessageActionAttempt.attempt_number.desc())
.first()
)
if attempt is not None:
attempt.status = "outcome_unknown"
attempt.completed_at = now
attempt.outcome_code = "replayed_in_progress"
attempt.diagnostic_summary = action.error_message
session.add(attempt)
session.add(action)
session.commit()
def _validate_single_message_action(
*,
version: CampaignVersion,
job: CampaignJob,
kind: str,
include_warnings: bool,
) -> None:
_ensure_version_validated_and_locked(version)
if job.build_status != JobBuildStatus.BUILT.value:
raise QueueingError("This message has not been built yet.")
if not _single_job_validation_allowed(
version,
job,
include_warnings=include_warnings,
):
raise QueueingError(
f"This message cannot be sent while validation status is {job.validation_status}."
)
if not job.eml_sha256 or (not job.eml_local_path and not job.eml_storage_key):
raise QueueingError(
"This message has no immutable generated EML evidence. Rebuild the campaign before sending."
)
if DeliveryChannelPolicy(job.delivery_channel_policy) != DeliveryChannelPolicy.MAIL:
raise QueueingError(
"Single-message SMTP actions currently require a Mail-only delivery policy."
)
if kind == "single_send":
if job.send_status in {
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
JobSendStatus.OUTCOME_UNKNOWN.value,
}:
raise QueueingError(
f"This message is in delivery state {job.send_status}; reconcile or wait before sending it."
)
if job.attempt_count > 0 or job.send_status not in {
JobSendStatus.NOT_QUEUED.value,
JobSendStatus.CANCELLED.value,
JobSendStatus.QUEUED.value,
}:
raise QueueingError(
"Single-send is only available for a message without an official delivery attempt."
)
elif kind == "single_resend" and job.send_status in {
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
JobSendStatus.OUTCOME_UNKNOWN.value,
}:
raise QueueingError(
f"This message is in delivery state {job.send_status}; reconcile it before resending."
)
def _start_single_message_action_attempt(
session: Session,
action: CampaignMessageAction,
) -> CampaignMessageActionAttempt:
now = _utcnow()
attempt = CampaignMessageActionAttempt(
action_id=action.id,
attempt_number=1,
status="effect_in_progress",
started_at=now,
effect_started_at=now,
)
action.status = "effect_in_progress"
action.effect_started_at = now
session.add(action)
session.add(attempt)
session.commit()
return attempt
def _finish_single_message_action(
session: Session,
*,
action: CampaignMessageAction,
status: str,
attempt: CampaignMessageActionAttempt | None = None,
accepted_count: int = 0,
refused_recipients: dict[str, dict[str, Any]] | None = None,
error_type: str | None = None,
error_message: str | None = None,
final_send_status: str | None = None,
) -> None:
now = _utcnow()
refusals = refused_recipients or {}
action.status = status
action.accepted_count = accepted_count
action.refused_count = len(refusals)
action.refusal_summary = dict(
Counter(
str(item.get("classification") or "unknown")
for item in refusals.values()
)
)
action.error_type = str(error_type)[:120] if error_type else None
action.error_message = (
" ".join(str(error_message).split())[:500]
if error_message
else None
)
action.final_send_status = final_send_status
action.completed_at = now
session.add(action)
if attempt is not None:
attempt.status = status
attempt.completed_at = now
attempt.accepted_count = accepted_count
attempt.refused_count = len(refusals)
attempt.outcome_code = action.error_type or status
attempt.diagnostic_summary = action.error_message
session.add(attempt)
session.commit()
audit_event(
session,
tenant_id=action.tenant_id,
user_id=action.actor_user_id,
api_key_id=action.actor_api_key_id,
action="campaign.message_action_completed",
object_type="campaign_message_action",
object_id=action.id,
details={
"campaign_id": action.campaign_id,
"campaign_version_id": action.campaign_version_id,
"job_id": action.job_id,
"kind": action.kind,
"status": status,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
"recipient_count": action.recipient_count,
"accepted_count": accepted_count,
"refused_count": len(refusals),
"refusal_summary": action.refusal_summary,
"prior_send_status": action.prior_send_status,
"final_send_status": final_send_status,
"linked_send_attempt_id": action.linked_send_attempt_id,
"reason": action.reason,
},
)
session.commit()
def _single_action_status_from_result(status: str) -> str:
if status in DELIVERY_ACCEPTED_STATUSES or status == "already_accepted":
return "accepted"
if status == JobSendStatus.OUTCOME_UNKNOWN.value:
return "outcome_unknown"
if status == JobSendStatus.FAILED_TEMPORARY.value:
return "failed_temporary"
if status == JobSendStatus.FAILED_PERMANENT.value:
return "failed_permanent"
return status
def _single_action_status_from_job(job: CampaignJob) -> str:
return _single_action_status_from_result(job.send_status)
def _single_message_action_response(
action: CampaignMessageAction,
*,
duplicate: bool = False,
) -> dict[str, Any]:
return {
"campaign_id": campaign.id,
"version_id": version.id,
"job_id": job.id,
"action": "single_send",
"queue_action": queue_action,
"dry_run": False,
"result": result.as_dict(),
"campaign_id": action.campaign_id,
"version_id": action.campaign_version_id,
"job_id": action.job_id,
"action_id": action.id,
"action": action.kind,
"kind": action.kind,
"status": action.status,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
"recipient_count": action.recipient_count,
"prior_send_status": action.prior_send_status,
"final_send_status": action.final_send_status,
"accepted_count": action.accepted_count,
"refused_count": action.refused_count,
"refusal_summary": dict(action.refusal_summary or {}),
"reason": action.reason,
"linked_send_attempt_id": action.linked_send_attempt_id,
"created_at": action.created_at,
"effect_started_at": action.effect_started_at,
"completed_at": action.completed_at,
"duplicate": duplicate,
"result": SendJobResult(
job_id=action.job_id,
status=action.status,
attempt_number=action.prior_attempt_count,
message=action.error_message,
).as_dict(),
}
def _send_single_message_direct(
session: Session,
*,
campaign: Campaign,
version: CampaignVersion,
job: CampaignJob,
action: CampaignMessageAction,
delivery_context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> dict[str, Any]:
if (
delivery_context.envelope_from is None
or not delivery_context.envelope_recipients
):
_finish_single_message_action(
session,
action=action,
status="initiation_failed",
error_type="SmtpConfigurationError",
error_message="Mail delivery has no frozen envelope sender or recipients.",
final_send_status=job.send_status,
)
raise SmtpConfigurationError(
"Mail delivery has no frozen envelope sender or recipients."
)
mail_integration().wait_for_rate_limit(
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}:single-action",
messages_per_minute=delivery_context.snapshot.delivery.rate_limit.messages_per_minute,
enabled=use_rate_limit,
)
attempt = _start_single_message_action_attempt(session, action)
try:
result = mail_integration().send_campaign_email_bytes(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
profile_id=delivery_context.snapshot.mail_profile_id,
message_bytes=delivery_context.message_bytes,
envelope_from=delivery_context.envelope_from,
envelope_recipients=delivery_context.envelope_recipients,
from_header=_from_header_from_job(job),
expected_smtp_transport_revision=(
delivery_context.snapshot.smtp_transport_revision or ""
),
smtp_server_id=delivery_context.snapshot.smtp_server_id,
smtp_credential_id=delivery_context.snapshot.smtp_credential_id,
)
except SmtpSendError as exc:
if exc.outcome_unknown:
status = "outcome_unknown"
elif exc.temporary:
status = "failed_temporary"
else:
status = "failed_permanent"
_finish_single_message_action(
session,
action=action,
attempt=attempt,
status=status,
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
return _single_message_action_response(action)
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
_finish_single_message_action(
session,
action=action,
attempt=attempt,
status="failed_permanent",
error_type=exc.__class__.__name__,
error_message=str(exc),
final_send_status=job.send_status,
)
return _single_message_action_response(action)
except Exception:
_finish_single_message_action(
session,
action=action,
attempt=attempt,
status="outcome_unknown",
error_type="OutcomeUnknown",
error_message=(
"The provider effect started, but the delivery outcome could "
"not be established."
),
final_send_status=job.send_status,
)
return _single_message_action_response(action)
refusals = dict(result.refused_recipients)
accepted_count = result.accepted_count
if accepted_count <= 0:
classifications = {
str(item.get("classification") or "unknown")
for item in refusals.values()
}
if classifications and classifications <= {"temporary"}:
status = "failed_temporary"
elif "unknown" in classifications:
status = "outcome_unknown"
else:
status = "failed_permanent"
else:
status = "accepted_with_refusals" if refusals else "accepted"
if action.kind == "single_resend" and accepted_count > 0:
if job.send_status not in FULLY_ACCEPTED_STATUSES:
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.SMTP_ACCEPTED.value
job.sent_at = _utcnow()
job.outcome_unknown_at = None
job.last_error = (
"Some SMTP recipients were refused during explicit resend."
if refusals
else None
)
job.imap_status = (
JobImapStatus.PENDING.value
if delivery_context.snapshot.delivery.imap_append_sent.enabled
else JobImapStatus.NOT_REQUESTED.value
)
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(job)
if campaign.current_version_id == version.id:
_update_campaign_after_job(
session,
job.campaign_id,
job.campaign_version_id,
)
session.commit()
if (
enqueue_imap_task
and _celery_enabled()
and job.imap_status == JobImapStatus.PENDING.value
):
try:
_celery_enqueue_append_sent_job(job.id)
except Exception:
pass
_finish_single_message_action(
session,
action=action,
attempt=attempt,
status=status,
accepted_count=accepted_count,
refused_recipients=refusals,
error_type=(
None
if accepted_count > 0
else f"Smtp{status.title().replace('_', '')}"
),
error_message=(
None
if accepted_count > 0
else "SMTP did not accept an envelope recipient."
),
final_send_status=job.send_status,
)
return _single_message_action_response(action)
def _prepare_single_job_for_send(
session: Session,
*,