feat: pause campaign batches on SMTP failure
This commit is contained in:
@@ -176,7 +176,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
|
||||
delivery=DeliveryConfig(),
|
||||
)
|
||||
|
||||
assert payload["snapshot_version"] == "8"
|
||||
assert payload["snapshot_version"] == "9"
|
||||
assert payload["mail_profile_id"] == "profile-1"
|
||||
assert "smtp" not in payload
|
||||
assert "imap" not in payload
|
||||
|
||||
@@ -38,6 +38,11 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
|
||||
failed_count=1,
|
||||
outcome_unknown_count=0,
|
||||
skipped_count=0,
|
||||
paused_count=1,
|
||||
batch_state="paused",
|
||||
batch_pause_reason_code="smtp_authentication_failed",
|
||||
smtp_connection_count=1,
|
||||
smtp_reconnect_count=0,
|
||||
preflight_count=2,
|
||||
synchronous_send_policy={
|
||||
"max_recipient_jobs": 25,
|
||||
@@ -113,7 +118,12 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
|
||||
"failed_count",
|
||||
"outcome_unknown_count",
|
||||
"skipped_count",
|
||||
"paused_count",
|
||||
"preflight_count",
|
||||
"batch_state",
|
||||
"batch_pause_reason_code",
|
||||
"smtp_connection_count",
|
||||
"smtp_reconnect_count",
|
||||
"delivery_mode",
|
||||
"dry_run",
|
||||
"synchronous_send_policy",
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
JobBuildStatus,
|
||||
@@ -17,6 +17,7 @@ from govoplan_campaign.backend.sending.jobs import (
|
||||
_select_campaign_jobs_for_queue,
|
||||
_send_claimed_campaign_job,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import SmtpSendError
|
||||
|
||||
|
||||
class FakeSession:
|
||||
@@ -220,6 +221,62 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
||||
self.assertTrue(session.rolled_back)
|
||||
self.assertIn("Automatic retry is stopped", mark_unknown.call_args.kwargs["reason"])
|
||||
|
||||
def test_systemic_smtp_failure_pauses_remaining_campaign_jobs(self):
|
||||
job = SimpleNamespace(
|
||||
id="job-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
campaign_version_id="version-1",
|
||||
delivery_channel_policy="mail",
|
||||
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
snapshot=SimpleNamespace(
|
||||
mail_profile_id="profile-1",
|
||||
smtp_server_id=None,
|
||||
smtp_credential_id=None,
|
||||
smtp_transport_revision="revision-1",
|
||||
delivery=SimpleNamespace(rate_limit=SimpleNamespace(messages_per_minute=60)),
|
||||
),
|
||||
message_bytes=b"message",
|
||||
envelope_from="sender@example.test",
|
||||
envelope_recipients=["recipient@example.test"],
|
||||
)
|
||||
|
||||
class Mail:
|
||||
def wait_for_rate_limit(self, **_kwargs):
|
||||
return None
|
||||
|
||||
def send_campaign_email_bytes(self, *_args, **_kwargs):
|
||||
raise SmtpSendError(
|
||||
"SMTP authentication failed.",
|
||||
systemic=True,
|
||||
reason_code="smtp_authentication_failed",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
|
||||
patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=SimpleNamespace(attempt_number=1)),
|
||||
patch("govoplan_campaign.backend.sending.jobs._record_smtp_send_error", return_value=None),
|
||||
patch("govoplan_campaign.backend.sending.jobs._pause_jobs_after_systemic_smtp_failure", return_value=4) as pause,
|
||||
self.assertRaises(SmtpSendError),
|
||||
):
|
||||
_send_claimed_campaign_job(
|
||||
object(), # type: ignore[arg-type]
|
||||
job=job, # type: ignore[arg-type]
|
||||
claim_token="claim-1",
|
||||
context=context, # type: ignore[arg-type]
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
pause.assert_called_once_with(
|
||||
ANY,
|
||||
campaign_id="campaign-1",
|
||||
exclude_job_id="job-1",
|
||||
reason_code="smtp_authentication_failed",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -23,7 +24,9 @@ from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueCampaignResult,
|
||||
SynchronousSendRejected,
|
||||
_ensure_synchronous_send_count_allowed,
|
||||
_pause_jobs_after_systemic_smtp_failure,
|
||||
_preflight_synchronous_send_batch,
|
||||
_synchronous_smtp_batch_manager,
|
||||
queue_campaign_jobs,
|
||||
send_campaign_now,
|
||||
synchronous_send_candidate_jobs,
|
||||
@@ -364,6 +367,72 @@ def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
|
||||
provider.send_campaign_email_bytes.assert_not_called()
|
||||
|
||||
|
||||
def test_smtp_batch_manager_preflights_combined_recipients_and_frozen_transport() -> None:
|
||||
snapshot = SimpleNamespace(
|
||||
uses_mail=True,
|
||||
mail_profile_id="profile-1",
|
||||
smtp_transport_revision="revision-1",
|
||||
smtp_server_id="server-1",
|
||||
smtp_credential_id="credential-1",
|
||||
)
|
||||
jobs = [
|
||||
SimpleNamespace(
|
||||
id="one",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
delivery_channel_policy="mail",
|
||||
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="two",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
delivery_channel_policy="mail",
|
||||
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||
),
|
||||
]
|
||||
contexts = {
|
||||
"one": SimpleNamespace(snapshot=snapshot, envelope_from="sender@example.test", envelope_recipients=["one@example.test"]),
|
||||
"two": SimpleNamespace(snapshot=snapshot, envelope_from="sender@example.test", envelope_recipients=["two@example.test", "one@example.test"]),
|
||||
}
|
||||
state = SimpleNamespace(connection_count=1, reconnect_count=0)
|
||||
|
||||
@contextmanager
|
||||
def batch(_session, **kwargs):
|
||||
batch.kwargs = kwargs
|
||||
yield state
|
||||
|
||||
provider = SimpleNamespace(campaign_smtp_batch=batch)
|
||||
with patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=provider):
|
||||
with _synchronous_smtp_batch_manager(object(), jobs=jobs, contexts=contexts) as opened: # type: ignore[arg-type]
|
||||
assert opened is state
|
||||
|
||||
assert batch.kwargs["envelope_recipients"] == ["one@example.test", "two@example.test"]
|
||||
assert batch.kwargs["expected_smtp_transport_revision"] == "revision-1"
|
||||
|
||||
|
||||
def test_systemic_failure_pauses_only_remaining_queued_jobs() -> None:
|
||||
session = Mock()
|
||||
session.query.return_value.filter.return_value.update.return_value = 3
|
||||
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1", status="sending")
|
||||
session.get.return_value = campaign
|
||||
with patch("govoplan_campaign.backend.sending.jobs.audit_event") as audit:
|
||||
paused = _pause_jobs_after_systemic_smtp_failure(
|
||||
session,
|
||||
campaign_id="campaign-1",
|
||||
exclude_job_id="failed-job",
|
||||
reason_code="smtp_authentication_failed",
|
||||
)
|
||||
|
||||
assert paused == 3
|
||||
assert campaign.status == "ready_to_queue"
|
||||
session.commit.assert_called_once_with()
|
||||
assert audit.call_args.kwargs["details"] == {
|
||||
"reason_code": "smtp_authentication_failed",
|
||||
"paused_count": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_rejected_synchronous_preflight_rolls_back_staged_queue_before_audit() -> None:
|
||||
session = Mock()
|
||||
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||
|
||||
Reference in New Issue
Block a user