Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

View File

@@ -2,36 +2,41 @@ from __future__ import annotations
from email import policy
from email.message import EmailMessage
from importlib import import_module
from types import ModuleType
from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_core.core.optional import reraise_unless_missing_package
from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
prepared_campaign_snapshot,
public_attachment_summary_payload,
)
from govoplan_mail.backend.dev.mock_mailbox import (
clear_records,
consume_fail_next_imap,
consume_fail_next_smtp,
get_failures,
list_records,
record_imap_append,
record_smtp_delivery,
)
from govoplan_campaign.backend.integrations import files_integration
class MockCampaignSendError(RuntimeError):
pass
def _mock_mailbox() -> ModuleType | None:
try:
return import_module("govoplan_mail.backend.dev.mock_mailbox")
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_mail")
return None
def _require_mock_mailbox() -> ModuleType:
mailbox = _mock_mailbox()
if mailbox is None:
raise MockCampaignSendError("Mail module is not available")
return mailbox
def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None:
if address is None:
return None
@@ -47,7 +52,7 @@ def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]:
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
return [public_attachment_summary_payload(attachment) for attachment in message.attachments]
return [files_integration().public_attachment_summary_payload(attachment) for attachment in message.attachments]
def _message_payload(message: MessageDraft) -> dict[str, Any]:
@@ -92,7 +97,8 @@ def _raw_message_bytes(message: EmailMessage) -> bytes:
def _smtp_rejection_matches(recipients: list[str]) -> list[str]:
needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower()
mailbox = _mock_mailbox()
needle = ((mailbox.get_failures() if mailbox else {}).get("smtp_reject_recipients_containing") or "").strip().lower()
if not needle:
return []
return [recipient for recipient in recipients if needle in recipient.lower()]
@@ -148,10 +154,12 @@ def run_mock_campaign_send(
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError("Campaign version not found or not part of campaign")
if clear_mailbox:
clear_records()
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
if clear_mailbox and mailbox is not None:
mailbox.clear_records()
with prepared_campaign_snapshot(
files = files_integration()
with files.prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
@@ -163,7 +171,7 @@ def run_mock_campaign_send(
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
send_results: list[dict[str, Any]] = []
sent_count = 0
@@ -206,14 +214,14 @@ def run_mock_campaign_send(
continue
try:
if consume_fail_next_smtp():
if mailbox is not None and mailbox.consume_fail_next_smtp():
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
rejected = _smtp_rejection_matches(recipients)
if rejected and len(rejected) == len(recipients):
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
accepted = [recipient for recipient in recipients if recipient not in rejected]
smtp_record = record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
smtp_record = mailbox.record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
sent_count += 1
row.update({
"status": "sent",
@@ -226,14 +234,14 @@ def run_mock_campaign_send(
if append_sent:
try:
if consume_fail_next_imap():
if mailbox is not None and mailbox.consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = "Sent"
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
folder = config.delivery.imap_append_sent.folder
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
folder = config.server.imap.sent_folder
imap_record = record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
imap_appended_count += 1
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
except Exception as exc:
@@ -286,5 +294,5 @@ def run_mock_campaign_send(
"imap_failed_count": imap_failed_count,
"results": send_results,
},
"mailbox": {"messages": list_records(limit=200)},
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
}