feat(campaign): bound synchronous delivery

This commit is contained in:
2026-07-22 08:21:42 +02:00
parent a8c0750dd7
commit 7e1660344d
10 changed files with 689 additions and 45 deletions

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Mapping
from sqlalchemy.orm import Session
from govoplan_core.tenancy.scope import Tenant
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS = 25
ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS = 500
SYNCHRONOUS_SEND_MAX_ENV = "GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS"
CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY = "campaign_delivery_policy"
SYNCHRONOUS_SEND_MAX_SETTINGS_KEY = "synchronous_send_max_recipients"
class CampaignDeliveryPolicyError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class SynchronousSendPolicy:
max_recipient_jobs: int
source: str
deployment_max_recipient_jobs: int
tenant_max_recipient_jobs: int | None = None
def as_dict(self) -> dict[str, Any]:
return {
"max_recipient_jobs": self.max_recipient_jobs,
"source": self.source,
"deployment_max_recipient_jobs": self.deployment_max_recipient_jobs,
"tenant_max_recipient_jobs": self.tenant_max_recipient_jobs,
"deployment_setting": SYNCHRONOUS_SEND_MAX_ENV,
"tenant_setting": (
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
),
}
def effective_synchronous_send_policy(
session: Session,
*,
tenant_id: str,
environ: Mapping[str, str] | None = None,
) -> SynchronousSendPolicy:
env = os.environ if environ is None else environ
deployment_value = _configured_limit(
env.get(SYNCHRONOUS_SEND_MAX_ENV),
source=SYNCHRONOUS_SEND_MAX_ENV,
default=DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
)
tenant = session.get(Tenant, tenant_id)
tenant_raw = _tenant_limit_value(tenant.settings if tenant is not None else None)
if tenant_raw is None:
return SynchronousSendPolicy(
max_recipient_jobs=deployment_value,
source=("deployment" if env.get(SYNCHRONOUS_SEND_MAX_ENV) not in (None, "") else "deployment_default"),
deployment_max_recipient_jobs=deployment_value,
)
tenant_value = _configured_limit(
tenant_raw,
source=(
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
),
)
effective_value = min(deployment_value, tenant_value)
return SynchronousSendPolicy(
max_recipient_jobs=effective_value,
source="tenant" if tenant_value <= deployment_value else "deployment_ceiling",
deployment_max_recipient_jobs=deployment_value,
tenant_max_recipient_jobs=tenant_value,
)
def _tenant_limit_value(settings: Mapping[str, Any] | None) -> object | None:
if not isinstance(settings, Mapping):
return None
policy = settings.get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY)
if not isinstance(policy, Mapping):
return None
return policy.get(SYNCHRONOUS_SEND_MAX_SETTINGS_KEY)
def _configured_limit(value: object, *, source: str, default: int | None = None) -> int:
if value is None or (isinstance(value, str) and not value.strip()):
if default is not None:
return default
raise CampaignDeliveryPolicyError(f"{source} must be configured as an integer")
if isinstance(value, bool):
raise CampaignDeliveryPolicyError(f"{source} must be an integer, not a boolean")
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise CampaignDeliveryPolicyError(f"{source} must be an integer") from exc
if str(parsed) != str(value).strip() and not isinstance(value, int):
raise CampaignDeliveryPolicyError(f"{source} must be an integer")
if parsed < 0 or parsed > ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS:
raise CampaignDeliveryPolicyError(
f"{source} must be between 0 and {ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS}"
)
return parsed

View File

@@ -2,6 +2,11 @@ from __future__ import annotations
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
effective_synchronous_send_policy,
)
_CAMPAIGN_USER_SCOPES = (
"campaigns:campaign:read",
@@ -278,21 +283,20 @@ CAMPAIGN_USER_DOCUMENTATION = (
),
steps=(
"Open Review and send and confirm the selected version, build, recipients, warnings, and review completion.",
"Use an authorized supporting client to run the queue dry run and resolve every blocker.",
"Invoke the Queue action through that client so background workers can process the build.",
"Open the campaign Report and monitor queue, SMTP, IMAP, failure, and uncertain-outcome counts.",
"Select Queue for workers and confirm the exact durable execution that will be committed.",
"Remain on Review and send or leave and return later; both views read the same durable job states.",
"Use the delivery controls to pause, resume, cancel unsent work, or retry eligible failures without requeueing accepted or uncertain outcomes.",
),
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
related_modules=("mail", "notifications"),
limitations=("The current Campaign Web UI does not expose the Queue action; use an authorized supporting client or API.",),
),
_workflow_topic(
topic_id="campaigns.workflow.send-small-controlled-run",
title="Send a campaign immediately",
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
body="The current synchronous action does not enforce a server-side job-count limit. It must not replace the worker queue for ordinary batches, but it does preserve the same protected SMTP, IMAP, retry, and uncertain-outcome rules.",
body="Send now is protected by an effective deployment/tenant recipient-job maximum. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect.",
order=36,
audience=("campaign_sender", "campaign_operator"),
required_modules=("campaigns", "mail"),
@@ -308,12 +312,12 @@ CAMPAIGN_USER_DOCUMENTATION = (
help_contexts=("campaign.review-send",),
prerequisites=(
"The selected version is validated, locked, built, and reviewed.",
"An operator has deliberately confirmed that this is a small controlled run; the server does not currently enforce that bound.",
"The exact eligible recipient-job count is within the effective limit shown on Review and send.",
"Its Mail profile remains available and authorized for the current campaign context.",
),
steps=(
"Open Review and send and inspect the exact build and delivery readiness checks.",
"Confirm that this is an intentionally small controlled run; use Queue for an ordinary batch.",
"Review the effective synchronous limit and exact eligible count; use Queue for workers when Send now is unavailable or for an ordinary batch.",
"Select Send now and confirm the protected delivery action.",
"Open Report and inspect each resulting delivery state before attempting any recovery action.",
),
@@ -321,7 +325,6 @@ CAMPAIGN_USER_DOCUMENTATION = (
verification="The Report distinguishes accepted, failed, unattempted, cancelled, and outcome-unknown jobs and retains their attempt history.",
related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"),
related_modules=("mail",),
limitations=("No server-side synchronous job-count limit is currently enforced; use Queue and workers for ordinary batches.",),
),
_workflow_topic(
topic_id="campaigns.workflow.view-delivery-report",
@@ -481,10 +484,13 @@ def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTo
current_configuration = list(_actor_capabilities(principal, mail_available=mail_available))
integration_configuration, integration_limitations = _integration_summary(context.registry, principal)
current_configuration.extend(integration_configuration)
delivery_configuration, delivery_limitations = _delivery_policy_summary(context)
current_configuration.extend(delivery_configuration)
limitations = [
"Access to a particular campaign still depends on its owner or sharing rules and on the campaign's current state.",
"Profile, file, and address pickers re-evaluate the actual resources visible in the selected campaign; this overview does not claim that an eligible item currently exists.",
*integration_limitations,
*delivery_limitations,
]
return (
@@ -640,6 +646,24 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
return tuple(configured), tuple(limitations)
def _delivery_policy_summary(context: DocumentationContext) -> tuple[tuple[str, ...], tuple[str, ...]]:
session = context.session
tenant_id = str(getattr(context.principal, "tenant_id", "") or "").strip()
if session is None or not tenant_id:
return (), ("The effective synchronous Campaign limit could not be resolved for this documentation request.",)
try:
policy = effective_synchronous_send_policy(session, tenant_id=tenant_id) # type: ignore[arg-type]
except CampaignDeliveryPolicyError as exc:
return (), (f"Synchronous Campaign delivery is disabled until its policy configuration is corrected: {exc}",)
return (
(
"Delivery policy: Send now is limited to "
f"{policy.max_recipient_jobs} eligible recipient job(s) for this tenant. "
"The exact built count and Queue for workers alternative are shown before confirmation."
),
), ()
def _append_if(
target: list[str],
principal: object,

View File

@@ -44,6 +44,7 @@ from govoplan_campaign.backend.schemas import (
CampaignSendJobRequest,
CampaignSendUnattemptedRequest,
CampaignResolveOutcomeRequest,
CampaignDeliveryOptionsResponse,
CampaignListResponse,
CampaignResponse,
CampaignVersionDetailResponse,
@@ -65,7 +66,7 @@ from govoplan_campaign.backend.schemas import (
SendCampaignNowRequest,
SendCampaignNowResponse,
)
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
from govoplan_core.core.change_sequence import (
@@ -148,6 +149,7 @@ from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, r
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
from govoplan_campaign.backend.sending.jobs import (
QueueingError,
SynchronousSendRejected,
cancel_campaign_jobs,
enqueue_pending_imap_appends,
pause_campaign_jobs,
@@ -158,6 +160,7 @@ from govoplan_campaign.backend.sending.jobs import (
resume_campaign_jobs,
send_campaign_now,
send_single_campaign_job,
synchronous_send_options,
)
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
@@ -3019,6 +3022,30 @@ def revoke_campaign_share(
# Queue / delivery control -------------------------------------------------
@router.get("/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse)
def campaign_delivery_options(
campaign_id: str,
version_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope("campaigns:campaign:send", "campaigns:campaign:queue")
),
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
try:
return CampaignDeliveryOptionsResponse(
**synchronous_send_options(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=version_id,
)
)
except QueueingError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
def queue_campaign(
campaign_id: str,
@@ -3264,12 +3291,7 @@ def send_campaign_now_endpoint(
):
_get_campaign_for_principal(session, campaign_id, principal, write=True)
_require_permission(principal, "campaigns:recipient:read")
"""Validate/build/queue and synchronously send every eligible job.
No server-side job-count bound is currently enforced. Ordinary batches
must use the queue/Celery flow; this endpoint is for deliberately small
interactive runs.
"""
"""Preflight and synchronously send a policy-bounded built execution."""
payload = payload or SendCampaignNowRequest()
try:
@@ -3320,6 +3342,20 @@ def send_campaign_now_endpoint(
commit=True,
)
return SendCampaignNowResponse(result=result)
except SynchronousSendRejected as exc:
audit_from_principal(
session,
principal,
action="campaign.send_now_rejected",
object_type="campaign",
object_id=campaign_id,
details={
**exc.audit_details(),
"version_id": payload.version_id,
},
commit=True,
)
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
except HTTPException:
raise
except (CampaignPersistenceError, QueueingError) as exc:

View File

@@ -512,6 +512,13 @@ class SendCampaignNowResponse(BaseModel):
result: dict[str, Any]
class CampaignDeliveryOptionsResponse(BaseModel):
campaign_id: str
version_id: str
worker_queue_available: bool
synchronous_send: dict[str, Any] = Field(default_factory=dict)
class MockCampaignSendRequest(BaseModel):
model_config = ConfigDict(extra="forbid")

View File

@@ -28,7 +28,17 @@ from govoplan_campaign.backend.db.models import (
ImapAppendAttempt,
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
SynchronousSendPolicy,
effective_synchronous_send_policy,
)
from govoplan_campaign.backend.sending.execution import (
ExecutionSnapshot,
ExecutionSnapshotError,
ensure_execution_snapshot,
profile_delivery_summary,
)
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.integrations import (
ImapAppendError,
@@ -49,6 +59,29 @@ class SendJobError(RuntimeError):
pass
class SynchronousSendRejected(QueueingError):
def __init__(
self,
message: str,
*,
reason: str,
eligible_count: int | None = None,
policy: SynchronousSendPolicy | None = None,
) -> None:
super().__init__(message)
self.reason = reason
self.eligible_count = eligible_count
self.policy = policy
def audit_details(self) -> dict[str, Any]:
return {
"delivery_mode": "synchronous",
"rejection_reason": self.reason,
"eligible_recipient_job_count": self.eligible_count,
"synchronous_send_policy": self.policy.as_dict() if self.policy is not None else None,
}
@dataclass(frozen=True, slots=True)
class QueueCampaignResult:
campaign_id: str
@@ -80,6 +113,8 @@ class SendCampaignNowResult:
failed_count: int
outcome_unknown_count: int
skipped_count: int
preflight_count: int = 0
synchronous_send_policy: dict[str, Any] | None = None
dry_run: bool = False
results: list[dict[str, Any]] | None = None
@@ -92,6 +127,9 @@ class SendCampaignNowResult:
"failed_count": self.failed_count,
"outcome_unknown_count": self.outcome_unknown_count,
"skipped_count": self.skipped_count,
"preflight_count": self.preflight_count,
"delivery_mode": "synchronous",
"synchronous_send_policy": self.synchronous_send_policy or {},
"dry_run": self.dry_run,
"results": self.results or [],
}
@@ -444,6 +482,115 @@ def _select_campaign_jobs_for_queue(
return queued, skipped_count, blocked_count
def synchronous_send_candidate_jobs(
version: CampaignVersion,
jobs: list[CampaignJob],
*,
include_warnings: bool = True,
) -> list[CampaignJob]:
"""Return the exact persisted job set an immediate send may attempt.
This projection is deliberately side-effect free so the delivery-options
API and adaptive documentation can explain the current built execution
without changing queue state. The final command repeats the projection
after queueing and before any SMTP effect.
"""
allowed_validation = _queue_validation_statuses(include_warnings=include_warnings)
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
candidates: list[CampaignJob] = []
for job in jobs:
if (
job.queue_status == JobQueueStatus.QUEUED.value
and job.send_status == JobSendStatus.QUEUED.value
):
candidates.append(job)
continue
if job.send_status in INITIAL_QUEUE_SKIPPED_SEND_STATUSES:
continue
if job.queue_status in INITIAL_QUEUE_SKIPPED_QUEUE_STATUSES:
continue
validation_allowed = job.validation_status in allowed_validation or (
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
and _job_review_key(job) in reviewed_needs_review_keys
)
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
continue
if not job.eml_local_path and not job.eml_storage_key:
continue
candidates.append(job)
return candidates
def synchronous_send_options(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
include_warnings: bool = True,
) -> dict[str, Any]:
"""Describe the actor-independent effective delivery-mode constraints."""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_version_for_campaign(session, campaign, version_id=version_id)
jobs = _campaign_jobs_for_version(session, tenant_id=tenant_id, version_id=version.id)
candidates = synchronous_send_candidate_jobs(version, jobs, include_warnings=include_warnings)
try:
policy = effective_synchronous_send_policy(session, tenant_id=tenant_id)
except CampaignDeliveryPolicyError as exc:
return {
"campaign_id": campaign.id,
"version_id": version.id,
"worker_queue_available": bool(_celery_enabled()),
"synchronous_send": {
"allowed": False,
"reason": "policy_configuration_invalid",
"message": str(exc),
"eligible_recipient_job_count": len(candidates),
"policy": {},
},
}
ready = _version_is_validated_and_locked(version) and bool(version.build_summary)
eligible_count = len(candidates)
if not ready:
reason = "version_not_ready"
elif eligible_count == 0:
reason = "no_eligible_recipient_jobs"
elif eligible_count > policy.max_recipient_jobs:
reason = "recipient_limit_exceeded"
else:
reason = None
return {
"campaign_id": campaign.id,
"version_id": version.id,
"worker_queue_available": bool(_celery_enabled()),
"synchronous_send": {
"allowed": reason is None,
"reason": reason,
"eligible_recipient_job_count": eligible_count,
"policy": policy.as_dict(),
},
}
def _campaign_jobs_for_version(
session: Session,
*,
tenant_id: str,
version_id: str,
) -> list[CampaignJob]:
return (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_version_id == version_id,
)
.order_by(CampaignJob.entry_index.asc())
.all()
)
def _persist_campaign_queue(
session: Session,
*,
@@ -546,13 +693,36 @@ def send_campaign_now(
) -> SendCampaignNowResult:
"""Queue and send all eligible jobs for one campaign synchronously.
The service currently has no server-side job-count bound. Callers must use
the worker queue for ordinary batches and reserve this path for deliberately
small interactive runs.
The effective deployment/tenant limit is checked against the immutable,
persisted job set before queue state changes. After queueing, every message
and transport revision is preflighted before the first SMTP effect starts.
"""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_current_version(session, campaign, version_id=version_id)
_ensure_version_validated_and_locked(version)
_ensure_campaign_execution_snapshot(session, version)
try:
synchronous_policy = effective_synchronous_send_policy(session, tenant_id=tenant_id)
except CampaignDeliveryPolicyError as exc:
raise SynchronousSendRejected(
f"Synchronous Campaign delivery is disabled by an invalid policy configuration: {exc}",
reason="policy_configuration_invalid",
) from exc
initial_jobs = _campaign_jobs_for_queue(
session,
tenant_id=tenant_id,
version_id=version.id,
)
initial_candidates = synchronous_send_candidate_jobs(
version,
initial_jobs,
include_warnings=include_warnings,
)
_ensure_synchronous_send_count_allowed(
len(initial_candidates),
policy=synchronous_policy,
)
queue_result = queue_campaign_jobs(
session,
@@ -572,20 +742,31 @@ def send_campaign_now(
failed_count=0,
outcome_unknown_count=0,
skipped_count=queue_result.skipped_count + queue_result.blocked_count,
preflight_count=len(initial_candidates),
synchronous_send_policy=synchronous_policy.as_dict(),
dry_run=True,
results=[queue_result.as_dict()],
)
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_version_id == version.id,
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
jobs = [
job
for job in _campaign_jobs_for_version(
session,
tenant_id=tenant_id,
version_id=version.id,
)
.order_by(CampaignJob.entry_index.asc())
.all()
if job.queue_status == JobQueueStatus.QUEUED.value
and job.send_status == JobSendStatus.QUEUED.value
]
# Repeat the hard bound against the post-queue set. This closes the window
# where a concurrent queue operation could otherwise enlarge an immediate
# run between the initial decision and the first provider effect.
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
delivery_contexts = _preflight_synchronous_send_batch(
session,
version=version,
jobs=jobs,
policy=synchronous_policy,
)
results: list[dict[str, Any]] = []
@@ -595,13 +776,19 @@ def send_campaign_now(
skipped_after_queue = 0
for job in jobs:
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,
)
claimed = _claimed_campaign_job_for_delivery(session, job)
if isinstance(claimed, SendJobResult):
result = claimed
else:
claimed_job, claim_token = claimed
result = _send_claimed_campaign_job(
session,
job=claimed_job,
claim_token=claim_token,
context=delivery_contexts[job.id],
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
result_dict = result.as_dict()
results.append(result_dict)
if result.status in {JobSendStatus.SMTP_ACCEPTED.value, "already_accepted"}:
@@ -622,11 +809,75 @@ def send_campaign_now(
failed_count=failed_count,
outcome_unknown_count=outcome_unknown_count,
skipped_count=queue_result.skipped_count + queue_result.blocked_count + skipped_after_queue,
preflight_count=len(delivery_contexts),
synchronous_send_policy=synchronous_policy.as_dict(),
dry_run=False,
results=results,
)
def _ensure_synchronous_send_count_allowed(
eligible_count: int,
*,
policy: SynchronousSendPolicy,
) -> None:
if eligible_count <= 0:
raise SynchronousSendRejected(
"No eligible built recipient messages are available for synchronous delivery.",
reason="no_eligible_recipient_jobs",
eligible_count=eligible_count,
policy=policy,
)
if eligible_count > policy.max_recipient_jobs:
raise SynchronousSendRejected(
(
f"This built run contains {eligible_count} eligible recipient jobs, above the effective "
f"synchronous limit of {policy.max_recipient_jobs}. Queue it for background workers instead."
),
reason="recipient_limit_exceeded",
eligible_count=eligible_count,
policy=policy,
)
def _preflight_synchronous_send_batch(
session: Session,
*,
version: CampaignVersion,
jobs: list[CampaignJob],
policy: SynchronousSendPolicy,
) -> dict[str, _SendJobDeliveryContext]:
"""Freeze and validate every local input before the first SMTP effect."""
contexts: dict[str, _SendJobDeliveryContext] = {}
try:
for job in jobs:
state_result = _preflight_send_campaign_job(session, job, dry_run=True)
if state_result is not None:
raise SendJobError(
f"Job {job.id} changed to {state_result.status} during synchronous preflight"
)
contexts[job.id] = _send_job_delivery_context(session, job)
profile_summary = profile_delivery_summary(session, version)
expected_revision = next(iter(contexts.values())).snapshot.smtp_transport_revision
if profile_summary.get("smtp_transport_revision") != expected_revision:
raise SendJobError(
"The selected Mail profile changed after this execution was built. Revalidate and rebuild before delivery."
)
except (ExecutionSnapshotError, MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
raise SynchronousSendRejected(
(
"Synchronous preflight stopped before contacting SMTP because one or more frozen message "
"inputs or the selected Mail profile no longer match the reviewed build. Rebuild and review "
"the version before trying again."
),
reason="batch_preflight_failed",
eligible_count=len(jobs),
policy=policy,
) from exc
return contexts
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
if not _celery_enabled():
return 0