security: harden Campaign delivery effects and reconciliation
This commit is contained in:
@@ -9,11 +9,16 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CampaignMailProfileBoundaryError,
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
campaign_mail_profile_id,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
||||
|
||||
SNAPSHOT_VERSION = "3"
|
||||
SNAPSHOT_VERSION = "5"
|
||||
|
||||
|
||||
class ExecutionSnapshotError(RuntimeError):
|
||||
@@ -25,9 +30,9 @@ class ExecutionSnapshot(BaseModel):
|
||||
|
||||
Rendered messages and attachment evidence remain normalized in
|
||||
``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the
|
||||
mutable transport/runtime configuration and cryptographically binds it to
|
||||
the build and the exact set of persisted jobs without duplicating all
|
||||
recipient data into one large JSON value.
|
||||
Mail-profile reference, delivery policy, and opaque transport revisions and
|
||||
cryptographically binds them to the build and exact persisted jobs. Mail
|
||||
owns the resolved transport configuration and credentials.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -35,6 +40,7 @@ class ExecutionSnapshot(BaseModel):
|
||||
snapshot_version: str = SNAPSHOT_VERSION
|
||||
campaign_version_id: str
|
||||
campaign_json_sha256: str
|
||||
mail_profile_id: str
|
||||
created_at: str
|
||||
build_token: str | None = None
|
||||
built_at: str | None = None
|
||||
@@ -42,10 +48,8 @@ class ExecutionSnapshot(BaseModel):
|
||||
queueable_job_count: int = 0
|
||||
job_manifest_sha256: str | None = None
|
||||
effective_policy_sha256: str | None = None
|
||||
smtp_config_fingerprint: str | None = None
|
||||
imap_config_fingerprint: str | None = None
|
||||
smtp: SmtpConfig
|
||||
imap: ImapConfig | None = None
|
||||
smtp_transport_revision: str | None = None
|
||||
imap_transport_revision: str | None = None
|
||||
delivery: DeliveryConfig
|
||||
|
||||
|
||||
@@ -61,110 +65,50 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
|
||||
return _sha256(payload)
|
||||
|
||||
|
||||
def _transport_fingerprint(config: SmtpConfig | ImapConfig | None) -> str | None:
|
||||
if config is None:
|
||||
return None
|
||||
payload = config.model_dump(mode="json")
|
||||
# The fingerprint is safe to expose in reports. It identifies the effective
|
||||
# account/transport settings without incorporating or revealing the secret.
|
||||
if "password" in payload:
|
||||
payload["password"] = "<configured>" if payload.get("password") else None
|
||||
return _sha256(payload)
|
||||
|
||||
|
||||
def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpConfig | ImapConfig | None:
|
||||
if config is None:
|
||||
return None
|
||||
payload = config.model_dump(mode="json")
|
||||
payload["password"] = None
|
||||
if isinstance(config, SmtpConfig):
|
||||
return SmtpConfig.model_validate(payload)
|
||||
return ImapConfig.model_validate(payload)
|
||||
|
||||
|
||||
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
credentials = server.get("credentials") if isinstance(server, dict) and isinstance(server.get("credentials"), dict) else None
|
||||
config = credentials.get(name) if isinstance(credentials, dict) and isinstance(credentials.get(name), dict) else None
|
||||
password = config.get("password") if isinstance(config, dict) else None
|
||||
if password is None:
|
||||
legacy_config = server.get(name) if isinstance(server, dict) else None
|
||||
password = legacy_config.get("password") if isinstance(legacy_config, dict) else None
|
||||
if password is None:
|
||||
return None
|
||||
return str(password)
|
||||
|
||||
|
||||
def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any]:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
return server if isinstance(server, dict) else {}
|
||||
|
||||
|
||||
def _profile_for_version(session: Session, version: CampaignVersion):
|
||||
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
mail = mail_integration()
|
||||
profile_id = mail.mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
if not profile_id:
|
||||
return None
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
|
||||
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
try:
|
||||
return mail.ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
|
||||
return mail.campaign_profile_delivery_summary(
|
||||
session,
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
except MailProfileError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
|
||||
|
||||
def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> SmtpConfig:
|
||||
payload = snapshot.smtp.model_dump(mode="json")
|
||||
if not payload.get("password"):
|
||||
server = _server_from_campaign_json(version.raw_json)
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is not None:
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
mail = mail_integration()
|
||||
profile_payload = mail.smtp_config_from_profile(profile).model_dump(mode="json")
|
||||
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
|
||||
return SmtpConfig.model_validate(profile_payload)
|
||||
return SmtpConfig.model_validate(mail.apply_campaign_credentials(profile_payload, server, "smtp"))
|
||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
|
||||
return SmtpConfig.model_validate(payload)
|
||||
def profile_transport_revisions(session: Session, version: CampaignVersion) -> dict[str, str | None]:
|
||||
summary = profile_delivery_summary(session, version)
|
||||
return {
|
||||
"smtp": summary.get("smtp_transport_revision"),
|
||||
"imap": summary.get("imap_transport_revision"),
|
||||
}
|
||||
|
||||
|
||||
def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> ImapConfig | None:
|
||||
server = _server_from_campaign_json(version.raw_json)
|
||||
if snapshot.imap is None:
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is None:
|
||||
return None
|
||||
mail = mail_integration()
|
||||
imap = mail.imap_config_from_profile(profile)
|
||||
if imap is None:
|
||||
return None
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
imap_payload = imap.model_dump(mode="json")
|
||||
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
||||
return ImapConfig.model_validate(imap_payload)
|
||||
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
|
||||
payload = snapshot.imap.model_dump(mode="json")
|
||||
if not payload.get("password"):
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is not None:
|
||||
mail = mail_integration()
|
||||
imap = mail.imap_config_from_profile(profile)
|
||||
if imap is not None:
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
imap_payload = imap.model_dump(mode="json")
|
||||
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
||||
return ImapConfig.model_validate(imap_payload)
|
||||
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
|
||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
|
||||
return ImapConfig.model_validate(payload)
|
||||
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
|
||||
raise ExecutionSnapshotError(
|
||||
"The campaign's Mail profile reference differs from the built execution snapshot. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
|
||||
|
||||
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
|
||||
try:
|
||||
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True)
|
||||
except CampaignMailProfileBoundaryError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
|
||||
|
||||
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
|
||||
@@ -208,8 +152,9 @@ def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
||||
def create_execution_snapshot(
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
smtp: SmtpConfig,
|
||||
imap: ImapConfig | None,
|
||||
mail_profile_id: str,
|
||||
smtp_transport_revision: str,
|
||||
imap_transport_revision: str | None,
|
||||
delivery: DeliveryConfig,
|
||||
jobs: Iterable[CampaignJob] = (),
|
||||
build_summary: dict[str, Any] | None = None,
|
||||
@@ -218,26 +163,19 @@ def create_execution_snapshot(
|
||||
job_list = list(jobs)
|
||||
summary = build_summary if isinstance(build_summary, dict) else {}
|
||||
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
||||
redacted_smtp = _redacted_transport_config(smtp)
|
||||
redacted_imap = _redacted_transport_config(imap)
|
||||
if not isinstance(redacted_smtp, SmtpConfig):
|
||||
raise ExecutionSnapshotError("Redacted SMTP configuration is invalid.")
|
||||
if redacted_imap is not None and not isinstance(redacted_imap, ImapConfig):
|
||||
raise ExecutionSnapshotError("Redacted IMAP configuration is invalid.")
|
||||
payload = ExecutionSnapshot(
|
||||
campaign_version_id=version.id,
|
||||
campaign_json_sha256=_sha256(raw_json),
|
||||
mail_profile_id=mail_profile_id,
|
||||
build_token=str(summary.get("build_token") or "") or None,
|
||||
built_at=str(summary.get("built_at") or "") or None,
|
||||
job_count=len(job_list),
|
||||
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
|
||||
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
|
||||
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
|
||||
smtp_config_fingerprint=_transport_fingerprint(smtp),
|
||||
imap_config_fingerprint=_transport_fingerprint(imap),
|
||||
smtp_transport_revision=smtp_transport_revision,
|
||||
imap_transport_revision=imap_transport_revision,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
smtp=redacted_smtp,
|
||||
imap=redacted_imap,
|
||||
delivery=delivery,
|
||||
).model_dump(mode="json")
|
||||
return payload, snapshot_hash(payload)
|
||||
@@ -251,27 +189,38 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
|
||||
without a manual data migration.
|
||||
"""
|
||||
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
try:
|
||||
assert_server_safe_campaign_paths(
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
raw_json,
|
||||
managed_files_available=files_integration().available,
|
||||
)
|
||||
except CampaignPathSecurityError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
|
||||
if isinstance(version.execution_snapshot, dict):
|
||||
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION:
|
||||
raise ExecutionSnapshotError(
|
||||
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
|
||||
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
|
||||
"and rebuild a new campaign version."
|
||||
)
|
||||
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
||||
expected = snapshot_hash(snapshot.model_dump(mode="json"))
|
||||
if version.execution_snapshot_hash and version.execution_snapshot_hash != expected:
|
||||
raise ExecutionSnapshotError("Execution snapshot checksum mismatch")
|
||||
_assert_snapshot_profile_matches_version(version, snapshot)
|
||||
return snapshot
|
||||
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||
|
||||
_, _, config = load_version_config(session, version.id)
|
||||
runtime_smtp = config.server.runtime_smtp_config()
|
||||
if not runtime_smtp:
|
||||
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise ExecutionSnapshotError("The selected Mail profile has no SMTP configuration")
|
||||
if profile_id is None:
|
||||
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
@@ -280,10 +229,14 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
|
||||
)
|
||||
if not jobs:
|
||||
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
||||
revisions = profile_transport_revisions(session, version)
|
||||
if not revisions["smtp"]:
|
||||
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
|
||||
payload, digest = create_execution_snapshot(
|
||||
version,
|
||||
smtp=runtime_smtp,
|
||||
imap=config.server.runtime_imap_config(),
|
||||
mail_profile_id=profile_id,
|
||||
smtp_transport_revision=revisions["smtp"],
|
||||
imap_transport_revision=revisions["imap"],
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
||||
|
||||
@@ -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