feat: harden campaign delivery and editing
This commit is contained in:
@@ -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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user