security: harden Campaign delivery effects and reconciliation

This commit is contained in:
2026-07-21 17:14:33 +02:00
parent 057e660b17
commit 09c63de813
25 changed files with 2079 additions and 708 deletions

View File

@@ -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(