security: harden Campaign delivery effects and reconciliation
This commit is contained in:
@@ -28,7 +28,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
ImapAppendAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
ImapAppendError,
|
||||
@@ -1040,18 +1040,29 @@ def reconcile_job_outcome(
|
||||
job_id: str,
|
||||
decision: str,
|
||||
note: str | None = None,
|
||||
commit: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Record an operator decision for an uncertain/incomplete worker state.
|
||||
|
||||
``smtp_accepted`` protects the message from retry and enables IMAP append.
|
||||
``not_sent`` converts it into a failed-temporary candidate; retry remains a
|
||||
separate explicit action.
|
||||
separate explicit action. ``imap_appended`` completes an uncertain append;
|
||||
only the evidence-backed ``imap_not_appended`` decision makes it retryable.
|
||||
"""
|
||||
|
||||
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")
|
||||
if decision in {"imap_appended", "imap_not_appended"}:
|
||||
return _reconcile_imap_append_outcome(
|
||||
session,
|
||||
campaign=campaign,
|
||||
job=job,
|
||||
decision=decision,
|
||||
note=note,
|
||||
commit=commit,
|
||||
)
|
||||
if job.send_status not in {
|
||||
JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
JobSendStatus.SENDING.value,
|
||||
@@ -1089,7 +1100,10 @@ def reconcile_job_outcome(
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
_update_campaign_after_job(session, campaign.id, version.id)
|
||||
session.commit()
|
||||
if commit:
|
||||
session.commit()
|
||||
else:
|
||||
session.flush()
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
@@ -1101,6 +1115,70 @@ def reconcile_job_outcome(
|
||||
}
|
||||
|
||||
|
||||
def _reconcile_imap_append_outcome(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
job: CampaignJob,
|
||||
decision: str,
|
||||
note: str | None,
|
||||
commit: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve an uncertain append while retaining its immutable attempt row."""
|
||||
|
||||
evidence_note = (note or "").strip()
|
||||
if not evidence_note:
|
||||
raise QueueingError("IMAP reconciliation requires an evidence note")
|
||||
if job.send_status not in SMTP_ACCEPTED_STATUSES:
|
||||
raise QueueingError("Only an SMTP-accepted job can reconcile a Sent-folder append")
|
||||
if job.imap_status not in {
|
||||
JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||
JobImapStatus.APPENDING.value,
|
||||
}:
|
||||
raise QueueingError(f"IMAP status {job.imap_status} does not require reconciliation")
|
||||
|
||||
attempt = (
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id == job.id)
|
||||
.order_by(ImapAppendAttempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
if decision == "imap_appended":
|
||||
job.imap_status = JobImapStatus.APPENDED.value
|
||||
attempt_status = "reconciled_imap_appended"
|
||||
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||
elif decision == "imap_not_appended":
|
||||
# FAILED is deliberately retryable only after this explicit,
|
||||
# evidence-backed operator decision.
|
||||
job.imap_status = JobImapStatus.FAILED.value
|
||||
attempt_status = "reconciled_imap_not_appended"
|
||||
else: # Kept defensive for non-HTTP callers.
|
||||
raise QueueingError("IMAP decision must be 'imap_appended' or 'imap_not_appended'")
|
||||
|
||||
job.imap_claimed_at = None
|
||||
job.imap_claim_token = None
|
||||
job.last_error = evidence_note
|
||||
if attempt is not None:
|
||||
attempt.status = attempt_status
|
||||
attempt.error_message = evidence_note
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
if commit:
|
||||
session.commit()
|
||||
else:
|
||||
session.flush()
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": job.campaign_version_id,
|
||||
"job_id": job.id,
|
||||
"channel": "imap",
|
||||
"decision": decision,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
"note": evidence_note,
|
||||
}
|
||||
|
||||
|
||||
def _verify_eml_evidence(job: CampaignJob, payload: bytes) -> None:
|
||||
if job.eml_size_bytes is not None and len(payload) != job.eml_size_bytes:
|
||||
raise SendJobError(
|
||||
@@ -1143,7 +1221,7 @@ def _addresses_from_job(job: CampaignJob, field: str) -> list[str]:
|
||||
return [item.get("email") for item in values if isinstance(item, dict) and item.get("email")]
|
||||
|
||||
|
||||
def _sender_from_job(job: CampaignJob, snapshot: ExecutionSnapshot) -> str:
|
||||
def _sender_from_job(job: CampaignJob) -> str:
|
||||
data = job.resolved_recipients or {}
|
||||
bounce_to = _addresses_from_job(job, "bounce_to")
|
||||
if bounce_to:
|
||||
@@ -1151,17 +1229,17 @@ def _sender_from_job(job: CampaignJob, snapshot: ExecutionSnapshot) -> str:
|
||||
from_data = data.get("from") if isinstance(data, dict) else None
|
||||
if isinstance(from_data, dict) and from_data.get("email"):
|
||||
return from_data["email"]
|
||||
if snapshot.smtp.username:
|
||||
return snapshot.smtp.username
|
||||
raise SmtpConfigurationError("No envelope sender could be determined")
|
||||
raise SmtpConfigurationError(
|
||||
"No envelope sender could be determined from Campaign-owned recipient data; configure recipients.from or bounce_to before building"
|
||||
)
|
||||
|
||||
|
||||
def _from_header_from_job(job: CampaignJob, snapshot: ExecutionSnapshot) -> str | None:
|
||||
def _from_header_from_job(job: CampaignJob) -> str | None:
|
||||
data = job.resolved_recipients or {}
|
||||
from_data = data.get("from") if isinstance(data, dict) else None
|
||||
if isinstance(from_data, dict) and from_data.get("email"):
|
||||
return str(from_data["email"])
|
||||
return snapshot.smtp.username
|
||||
return None
|
||||
|
||||
|
||||
def _recipients_from_job(job: CampaignJob) -> list[str]:
|
||||
@@ -1443,7 +1521,7 @@ def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDe
|
||||
raise SendJobError(str(exc)) from exc
|
||||
|
||||
message_bytes = _load_eml_bytes_for_job(job)
|
||||
envelope_from = _sender_from_job(job, snapshot)
|
||||
envelope_from = _sender_from_job(job)
|
||||
envelope_recipients = _recipients_from_job(job)
|
||||
if not envelope_recipients:
|
||||
raise SmtpConfigurationError("No envelope recipients could be determined")
|
||||
@@ -1504,30 +1582,19 @@ def _send_claimed_campaign_job(
|
||||
)
|
||||
attempt = _record_attempt_start(session, job, claim_token)
|
||||
try:
|
||||
smtp_config = runtime_smtp_config(session, context.version, context.snapshot)
|
||||
mail_integration().assert_mail_policy_allows_send(
|
||||
result = mail_integration().send_campaign_email_bytes(
|
||||
session,
|
||||
tenant_id=job.tenant_id,
|
||||
campaign_id=job.campaign_id,
|
||||
smtp=smtp_config,
|
||||
envelope_sender=context.envelope_from,
|
||||
from_header=_from_header_from_job(job, context.snapshot),
|
||||
recipients=context.envelope_recipients,
|
||||
)
|
||||
result = mail_integration().send_email_bytes(
|
||||
context.message_bytes,
|
||||
smtp_config=smtp_config,
|
||||
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),
|
||||
expected_smtp_transport_revision=context.snapshot.smtp_transport_revision or "",
|
||||
)
|
||||
return _record_smtp_send_success(
|
||||
session,
|
||||
job=job,
|
||||
attempt=attempt,
|
||||
snapshot=context.snapshot,
|
||||
result=result,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
if result.accepted_count <= 0:
|
||||
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
|
||||
except SmtpSendError as exc:
|
||||
outcome_unknown = _record_smtp_send_error(session, job=job, attempt=attempt, exc=exc)
|
||||
if outcome_unknown is not None:
|
||||
@@ -1536,6 +1603,41 @@ def _send_claimed_campaign_job(
|
||||
except (MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
|
||||
_record_permanent_send_error(session, job=job, attempt=attempt, exc=exc)
|
||||
raise
|
||||
try:
|
||||
success = _record_smtp_send_success(
|
||||
session,
|
||||
job=job,
|
||||
attempt=attempt,
|
||||
snapshot=context.snapshot,
|
||||
result=result,
|
||||
)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
current = session.get(CampaignJob, job.id)
|
||||
if current is None:
|
||||
raise SendJobError("SMTP accepted the message, but its durable Campaign job record is unavailable.") from None
|
||||
return mark_job_outcome_unknown(
|
||||
session,
|
||||
current,
|
||||
reason=(
|
||||
"SMTP accepted the provider effect, but persisting the accepted outcome failed. "
|
||||
"Automatic retry is stopped until an operator reconciles the job."
|
||||
),
|
||||
)
|
||||
if enqueue_imap_task and _celery_enabled() and job.imap_status == JobImapStatus.PENDING.value:
|
||||
try:
|
||||
_celery_enqueue_append_sent_job(job.id)
|
||||
except Exception:
|
||||
return SendJobResult(
|
||||
job_id=success.job_id,
|
||||
status=success.status,
|
||||
attempt_number=success.attempt_number,
|
||||
message=(
|
||||
f"{success.message + ' ' if success.message else ''}"
|
||||
"SMTP acceptance was persisted, but the Sent-folder append could not be enqueued; it remains pending."
|
||||
),
|
||||
)
|
||||
return success
|
||||
|
||||
|
||||
def _record_smtp_send_success(
|
||||
@@ -1545,10 +1647,7 @@ def _record_smtp_send_success(
|
||||
attempt: SendAttempt,
|
||||
snapshot: ExecutionSnapshot,
|
||||
result: object,
|
||||
enqueue_imap_task: bool,
|
||||
) -> SendJobResult:
|
||||
if result.accepted_count <= 0:
|
||||
raise SmtpSendError("SMTP did not accept any envelope recipients", temporary=False)
|
||||
refused_warning = _refused_recipient_warning(result)
|
||||
attempt.finished_at = _utcnow()
|
||||
attempt.status = "smtp_accepted_with_refusals" if refused_warning else JobSendStatus.SMTP_ACCEPTED.value
|
||||
@@ -1565,8 +1664,6 @@ def _record_smtp_send_success(
|
||||
session.add(job)
|
||||
_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:
|
||||
_celery_enqueue_append_sent_job(job.id)
|
||||
return SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||
@@ -1639,14 +1736,54 @@ def _record_permanent_send_error(
|
||||
session.commit()
|
||||
|
||||
|
||||
def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt:
|
||||
def _imap_attempt_count(session: Session, job_id: str) -> int:
|
||||
return session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job_id).count()
|
||||
|
||||
|
||||
def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None:
|
||||
"""Atomically grant one worker permission to invoke the IMAP provider."""
|
||||
|
||||
claim_token = str(uuid4())
|
||||
changed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.id == job.id,
|
||||
CampaignJob.send_status.in_(list(SMTP_ACCEPTED_STATUSES)),
|
||||
CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]),
|
||||
CampaignJob.imap_claim_token.is_(None),
|
||||
)
|
||||
.update(
|
||||
{
|
||||
CampaignJob.imap_status: JobImapStatus.APPENDING.value,
|
||||
CampaignJob.imap_claimed_at: _utcnow(),
|
||||
CampaignJob.imap_claim_token: claim_token,
|
||||
CampaignJob.last_error: None,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
return claim_token if changed == 1 else None
|
||||
|
||||
|
||||
def _record_imap_attempt_start(
|
||||
session: Session,
|
||||
job: CampaignJob,
|
||||
claim_token: str,
|
||||
) -> ImapAppendAttempt:
|
||||
if (
|
||||
job.imap_status != JobImapStatus.APPENDING.value
|
||||
or job.imap_claim_token != claim_token
|
||||
):
|
||||
raise SendJobError("The IMAP append claim was lost before the provider effect started")
|
||||
existing_count = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||
attempt = ImapAppendAttempt(
|
||||
job_id=job.id,
|
||||
attempt_number=existing_count + 1,
|
||||
status="running",
|
||||
claim_token=claim_token,
|
||||
)
|
||||
job.imap_status = JobImapStatus.PENDING.value
|
||||
job.last_error = None
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
@@ -1654,13 +1791,241 @@ def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppend
|
||||
return attempt
|
||||
|
||||
|
||||
def _effective_imap_folder(snapshot: ExecutionSnapshot) -> str:
|
||||
delivery_folder = snapshot.delivery.imap_append_sent.folder
|
||||
if delivery_folder and delivery_folder != "auto":
|
||||
return delivery_folder
|
||||
if snapshot.imap and snapshot.imap.sent_folder and snapshot.imap.sent_folder != "auto":
|
||||
return snapshot.imap.sent_folder
|
||||
return "auto"
|
||||
def _imap_blocked_result(
|
||||
session: Session,
|
||||
job: CampaignJob,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> AppendSentResult | None:
|
||||
if job.imap_status not in {
|
||||
JobImapStatus.NOT_REQUESTED.value,
|
||||
JobImapStatus.APPENDED.value,
|
||||
JobImapStatus.SKIPPED.value,
|
||||
JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||
JobImapStatus.APPENDING.value,
|
||||
}:
|
||||
return None
|
||||
attempts = _imap_attempt_count(session, job.id)
|
||||
if job.imap_status == JobImapStatus.NOT_REQUESTED.value:
|
||||
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=attempts, dry_run=dry_run)
|
||||
if job.imap_status == JobImapStatus.APPENDED.value:
|
||||
return AppendSentResult(job_id=job.id, status="already_appended", attempt_number=attempts, dry_run=dry_run)
|
||||
if job.imap_status == JobImapStatus.SKIPPED.value:
|
||||
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=attempts, dry_run=dry_run)
|
||||
if job.imap_status == JobImapStatus.OUTCOME_UNKNOWN.value:
|
||||
return AppendSentResult(
|
||||
job_id=job.id,
|
||||
status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||
attempt_number=attempts,
|
||||
dry_run=dry_run,
|
||||
message="The prior IMAP append outcome is unresolved; inspect and reconcile the mailbox before retrying.",
|
||||
)
|
||||
if job.imap_status == JobImapStatus.APPENDING.value:
|
||||
return AppendSentResult(
|
||||
job_id=job.id,
|
||||
status="append_in_progress",
|
||||
attempt_number=attempts,
|
||||
dry_run=dry_run,
|
||||
message="Another worker owns the IMAP append claim; automatic duplicate append was stopped.",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _imap_result_after_lost_claim(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
attempt: ImapAppendAttempt,
|
||||
provider_succeeded: bool,
|
||||
) -> AppendSentResult:
|
||||
session.rollback()
|
||||
current_attempt = session.get(ImapAppendAttempt, attempt.id)
|
||||
if current_attempt is not None:
|
||||
current_attempt.status = "late_ack_ignored" if provider_succeeded else "claim_lost"
|
||||
current_attempt.error_message = (
|
||||
"The provider returned after this attempt no longer owned the durable IMAP claim."
|
||||
)
|
||||
session.add(current_attempt)
|
||||
if provider_succeeded:
|
||||
# If no other worker is active, freeze any retryable state. A provider
|
||||
# success received after claim loss must never be followed by an
|
||||
# automatic duplicate append.
|
||||
session.query(CampaignJob).filter(
|
||||
CampaignJob.id == job_id,
|
||||
CampaignJob.imap_status.in_(
|
||||
[
|
||||
JobImapStatus.PENDING.value,
|
||||
JobImapStatus.FAILED.value,
|
||||
JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||
]
|
||||
),
|
||||
CampaignJob.imap_claim_token.is_(None),
|
||||
).update(
|
||||
{
|
||||
CampaignJob.imap_status: JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||
CampaignJob.last_error: (
|
||||
"An IMAP provider acknowledgement arrived after the durable claim was lost; operator reconciliation is required."
|
||||
),
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
current = session.get(CampaignJob, job_id)
|
||||
if current is None:
|
||||
raise SendJobError("Campaign job disappeared while reconciling a lost IMAP claim")
|
||||
blocked = _imap_blocked_result(session, current, dry_run=False)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
return AppendSentResult(
|
||||
job_id=current.id,
|
||||
status="claim_lost",
|
||||
attempt_number=_imap_attempt_count(session, current.id),
|
||||
message="The IMAP append claim changed while the provider operation was in progress.",
|
||||
)
|
||||
|
||||
|
||||
def _record_imap_append_failure(
|
||||
session: Session,
|
||||
*,
|
||||
job: CampaignJob,
|
||||
attempt: ImapAppendAttempt,
|
||||
claim_token: str,
|
||||
folder: str,
|
||||
message: str,
|
||||
outcome_unknown: bool,
|
||||
) -> bool:
|
||||
next_status = (
|
||||
JobImapStatus.OUTCOME_UNKNOWN.value
|
||||
if outcome_unknown
|
||||
else JobImapStatus.FAILED.value
|
||||
)
|
||||
changed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.id == job.id,
|
||||
CampaignJob.imap_status == JobImapStatus.APPENDING.value,
|
||||
CampaignJob.imap_claim_token == claim_token,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
CampaignJob.imap_status: next_status,
|
||||
CampaignJob.imap_claimed_at: None,
|
||||
CampaignJob.imap_claim_token: None,
|
||||
CampaignJob.last_error: message,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
attempt.status = next_status if outcome_unknown else "failed"
|
||||
attempt.folder = None if folder == "auto" else folder
|
||||
attempt.error_message = message
|
||||
session.add(attempt)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
return changed == 1
|
||||
|
||||
|
||||
def _record_imap_append_success(
|
||||
session: Session,
|
||||
*,
|
||||
job: CampaignJob,
|
||||
attempt: ImapAppendAttempt,
|
||||
claim_token: str,
|
||||
folder: str,
|
||||
) -> AppendSentResult:
|
||||
changed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.id == job.id,
|
||||
CampaignJob.imap_status == JobImapStatus.APPENDING.value,
|
||||
CampaignJob.imap_claim_token == claim_token,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
CampaignJob.imap_status: JobImapStatus.APPENDED.value,
|
||||
CampaignJob.imap_claimed_at: None,
|
||||
CampaignJob.imap_claim_token: None,
|
||||
CampaignJob.last_error: None,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if changed != 1:
|
||||
return _imap_result_after_lost_claim(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt=attempt,
|
||||
provider_succeeded=True,
|
||||
)
|
||||
attempt.status = "appended"
|
||||
attempt.folder = folder
|
||||
attempt.error_message = None
|
||||
session.add(attempt)
|
||||
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
return AppendSentResult(
|
||||
job_id=job.id,
|
||||
status="appended",
|
||||
attempt_number=attempt.attempt_number,
|
||||
folder=folder,
|
||||
)
|
||||
|
||||
|
||||
def _mark_imap_append_outcome_unknown_after_effect(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
attempt_id: str,
|
||||
claim_token: str,
|
||||
reason: str,
|
||||
) -> AppendSentResult:
|
||||
"""Freeze retry after a provider effect whose local persistence failed."""
|
||||
|
||||
session.rollback()
|
||||
current_attempt = session.get(ImapAppendAttempt, attempt_id)
|
||||
if current_attempt is not None:
|
||||
current_attempt.status = JobImapStatus.OUTCOME_UNKNOWN.value
|
||||
current_attempt.error_message = reason
|
||||
session.add(current_attempt)
|
||||
changed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.id == job_id,
|
||||
CampaignJob.imap_status == JobImapStatus.APPENDING.value,
|
||||
CampaignJob.imap_claim_token == claim_token,
|
||||
)
|
||||
.update(
|
||||
{
|
||||
CampaignJob.imap_status: JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||
CampaignJob.imap_claimed_at: None,
|
||||
CampaignJob.imap_claim_token: None,
|
||||
CampaignJob.last_error: reason,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
if changed != 1:
|
||||
if current_attempt is not None:
|
||||
session.rollback()
|
||||
current_attempt = session.get(ImapAppendAttempt, attempt_id)
|
||||
if current_attempt is None:
|
||||
raise SendJobError("IMAP append outcome is unknown and its attempt record is unavailable")
|
||||
return _imap_result_after_lost_claim(
|
||||
session,
|
||||
job_id=job_id,
|
||||
attempt=current_attempt,
|
||||
provider_succeeded=True,
|
||||
)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
return AppendSentResult(
|
||||
job_id=job_id,
|
||||
status=JobImapStatus.OUTCOME_UNKNOWN.value,
|
||||
attempt_number=_imap_attempt_count(session, job_id),
|
||||
message=reason,
|
||||
)
|
||||
|
||||
|
||||
def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult:
|
||||
@@ -1671,14 +2036,9 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
||||
raise SendJobError(f"Job not found: {job_id}")
|
||||
if job.send_status not in SMTP_ACCEPTED_STATUSES:
|
||||
return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="SMTP has not accepted this job")
|
||||
if job.imap_status == JobImapStatus.NOT_REQUESTED.value:
|
||||
return AppendSentResult(job_id=job_id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
||||
if job.imap_status == JobImapStatus.APPENDED.value:
|
||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||
return AppendSentResult(job_id=job_id, status="already_appended", attempt_number=attempts, dry_run=dry_run)
|
||||
if job.imap_status == JobImapStatus.SKIPPED.value:
|
||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||
return AppendSentResult(job_id=job_id, status="skipped", attempt_number=attempts, dry_run=dry_run)
|
||||
blocked = _imap_blocked_result(session, job, dry_run=dry_run)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
version = session.get(CampaignVersion, job.campaign_version_id)
|
||||
if not version:
|
||||
@@ -1692,16 +2052,15 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
||||
imap_config = runtime_imap_config(session, version, snapshot)
|
||||
if not imap_config:
|
||||
if not snapshot.imap_transport_revision:
|
||||
job.imap_status = JobImapStatus.SKIPPED.value
|
||||
job.last_error = "IMAP append requested, but the execution snapshot has no IMAP configuration"
|
||||
job.last_error = "IMAP append requested, but the selected Mail profile has no IMAP configuration"
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
|
||||
|
||||
message_bytes = _load_eml_bytes_for_job(job)
|
||||
folder = _effective_imap_folder(snapshot)
|
||||
folder = snapshot.delivery.imap_append_sent.folder or "auto"
|
||||
if dry_run:
|
||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||
return AppendSentResult(
|
||||
@@ -1713,39 +2072,95 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
|
||||
message=f"Would append {len(message_bytes)} bytes to IMAP folder {folder!r}",
|
||||
)
|
||||
|
||||
attempt = _record_imap_attempt_start(session, job)
|
||||
claim_token = _claim_job_for_imap_append(session, job)
|
||||
if claim_token is None:
|
||||
current = session.get(CampaignJob, job.id)
|
||||
if current is None:
|
||||
raise SendJobError(f"Job disappeared while claiming IMAP append: {job.id}")
|
||||
blocked = _imap_blocked_result(session, current, dry_run=False)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
return AppendSentResult(
|
||||
job_id=current.id,
|
||||
status="not_claimed",
|
||||
attempt_number=_imap_attempt_count(session, current.id),
|
||||
message=f"Job is no longer eligible for IMAP append: {current.imap_status}",
|
||||
)
|
||||
job = session.get(CampaignJob, job.id)
|
||||
if job is None:
|
||||
raise SendJobError("Claimed campaign job disappeared before IMAP append")
|
||||
attempt = _record_imap_attempt_start(session, job, claim_token)
|
||||
try:
|
||||
mail_integration().assert_mail_policy_allows_send(
|
||||
result = mail_integration().append_campaign_message_to_sent(
|
||||
session,
|
||||
tenant_id=job.tenant_id,
|
||||
campaign_id=job.campaign_id,
|
||||
smtp=snapshot.smtp,
|
||||
imap=imap_config,
|
||||
)
|
||||
result = mail_integration().append_message_to_sent(
|
||||
message_bytes,
|
||||
imap_config=imap_config,
|
||||
profile_id=snapshot.mail_profile_id,
|
||||
message_bytes=message_bytes,
|
||||
folder=None if folder == "auto" else folder,
|
||||
expected_smtp_transport_revision=snapshot.smtp_transport_revision or "",
|
||||
expected_imap_transport_revision=snapshot.imap_transport_revision,
|
||||
)
|
||||
attempt.status = "appended"
|
||||
attempt.folder = result.folder
|
||||
job.imap_status = JobImapStatus.APPENDED.value
|
||||
job.last_error = None
|
||||
files_integration().mark_job_attachment_uses_sent(session, job)
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return AppendSentResult(job_id=job.id, status="appended", attempt_number=attempt.attempt_number, folder=result.folder)
|
||||
except (MailProfileError, ImapConfigurationError, ImapAppendError, SendJobError, OSError) as exc:
|
||||
attempt.status = "failed"
|
||||
attempt.folder = None if folder == "auto" else folder
|
||||
attempt.error_message = str(exc)
|
||||
job.imap_status = JobImapStatus.FAILED.value
|
||||
job.last_error = str(exc)
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
except (MailProfileError, ImapConfigurationError, ImapAppendError) as exc:
|
||||
outcome_unknown = bool(getattr(exc, "outcome_unknown", False))
|
||||
owned = _record_imap_append_failure(
|
||||
session,
|
||||
job=job,
|
||||
attempt=attempt,
|
||||
claim_token=claim_token,
|
||||
folder=folder,
|
||||
message=str(exc),
|
||||
outcome_unknown=outcome_unknown,
|
||||
)
|
||||
if not owned:
|
||||
_imap_result_after_lost_claim(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt=attempt,
|
||||
provider_succeeded=outcome_unknown,
|
||||
)
|
||||
raise
|
||||
except Exception:
|
||||
reason = (
|
||||
"The Sent-folder append outcome is unknown after an unexpected provider failure; "
|
||||
"inspect and reconcile the mailbox before retrying."
|
||||
)
|
||||
owned = _record_imap_append_failure(
|
||||
session,
|
||||
job=job,
|
||||
attempt=attempt,
|
||||
claim_token=claim_token,
|
||||
folder=folder,
|
||||
message=reason,
|
||||
outcome_unknown=True,
|
||||
)
|
||||
if not owned:
|
||||
_imap_result_after_lost_claim(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt=attempt,
|
||||
provider_succeeded=True,
|
||||
)
|
||||
raise ImapAppendError(reason, outcome_unknown=True) from None
|
||||
try:
|
||||
return _record_imap_append_success(
|
||||
session,
|
||||
job=job,
|
||||
attempt=attempt,
|
||||
claim_token=claim_token,
|
||||
folder=result.folder,
|
||||
)
|
||||
except Exception:
|
||||
return _mark_imap_append_outcome_unknown_after_effect(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt_id=attempt.id,
|
||||
claim_token=claim_token,
|
||||
reason=(
|
||||
"The IMAP provider accepted the append, but persisting the outcome failed. "
|
||||
"Automatic retry is stopped until an operator reconciles the mailbox."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def enqueue_pending_imap_appends(
|
||||
|
||||
Reference in New Issue
Block a user