diff --git a/docs/CAMPAIGN_DELIVERY_RUNBOOK.md b/docs/CAMPAIGN_DELIVERY_RUNBOOK.md index 7eed86c..51becff 100644 --- a/docs/CAMPAIGN_DELIVERY_RUNBOOK.md +++ b/docs/CAMPAIGN_DELIVERY_RUNBOOK.md @@ -76,6 +76,13 @@ and uncertain or stranded effects remain `outcome_unknown` or `recovery_required` in Ops. Campaign jobs, message actions, and channel-attempt records remain the business source of truth. +For SMTP and Sent-folder APPEND, Campaign also passes the stable job/action and +attempt identifier into Mail. Mail establishes its own provider-bound recovery +fence after profile authorization and policy checks but before network I/O. +This nested ownership is intentional: Campaign proves its business transition, +while Mail proves the transport effect. Neither layer replays a completed or +unknown provider attempt merely to repair the other layer's state. + - `smtp_accepted`: Do not retry. If IMAP append is enabled and pending, run or enqueue the append action. - `failed_temporary`: Retry explicitly after checking the error and retry count. diff --git a/src/govoplan_campaign/backend/sending/jobs.py b/src/govoplan_campaign/backend/sending/jobs.py index ca1bbca..f45ad03 100644 --- a/src/govoplan_campaign/backend/sending/jobs.py +++ b/src/govoplan_campaign/backend/sending/jobs.py @@ -2321,6 +2321,11 @@ def _perform_single_message_direct_effect( ), smtp_server_id=delivery_context.snapshot.smtp_server_id, smtp_credential_id=delivery_context.snapshot.smtp_credential_id, + recovery_effect_id=( + f"campaign-single:{action.id}:smtp-attempt:{attempt.attempt_number}" + ), + recovery_resource_type="campaign_message_action", + recovery_resource_id=action.id, ) except SmtpSendError as exc: if exc.outcome_unknown: @@ -3633,6 +3638,11 @@ def _send_claimed_mail_only_job( or "", smtp_server_id=context.snapshot.smtp_server_id, smtp_credential_id=context.snapshot.smtp_credential_id, + recovery_effect_id=( + f"campaign-job:{job.id}:smtp-attempt:{attempt.attempt_number}" + ), + recovery_resource_type="campaign_job", + recovery_resource_id=job.id, ) if result.accepted_count <= 0: raise SmtpSendError( @@ -4780,6 +4790,11 @@ def _invoke_imap_append( smtp_credential_id=snapshot.smtp_credential_id, imap_server_id=snapshot.imap_server_id, imap_credential_id=snapshot.imap_credential_id, + recovery_effect_id=( + f"campaign-job:{job.id}:imap-attempt:{claimed.attempt.attempt_number}" + ), + recovery_resource_type="campaign_job", + recovery_resource_id=job.id, ) diff --git a/tests/test_sending_jobs.py b/tests/test_sending_jobs.py index aaf9f80..93974ad 100644 --- a/tests/test_sending_jobs.py +++ b/tests/test_sending_jobs.py @@ -181,7 +181,10 @@ class CampaignQueueSelectionTests(unittest.TestCase): ) with ( patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()), - patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=object()), + patch( + "govoplan_campaign.backend.sending.jobs._record_attempt_start", + return_value=SimpleNamespace(attempt_number=1), + ), patch( "govoplan_campaign.backend.sending.jobs._record_smtp_send_success", side_effect=OSError("storage unavailable"),