diff --git a/src/govoplan_mail/backend/capabilities.py b/src/govoplan_mail/backend/capabilities.py index c07db41..ef15fef 100644 --- a/src/govoplan_mail/backend/capabilities.py +++ b/src/govoplan_mail/backend/capabilities.py @@ -1,10 +1,13 @@ from __future__ import annotations from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formatdate, make_msgid from typing import Any from sqlalchemy.orm import Session +from govoplan_core.core.mail import NotificationMailDeliveryRequest from govoplan_core.core.modules import ModuleContext from govoplan_mail.backend.mail_profiles import ( MailProfileError, @@ -554,3 +557,102 @@ def delivery_outbox_capability(context: ModuleContext): configure_runtime(settings=context.settings) return MailDeliveryOutboxCapability() + + +class MailNotificationDeliveryCapability: + """Submit notification email to the Mail-owned durable outbox.""" + + def submit_notification_mail( + self, + session: Session, + request: NotificationMailDeliveryRequest, + ) -> dict[str, object]: + profile_id = str(request.mail_profile_id or "").strip() + from_address = str(request.from_address or "").strip() + if not profile_id or not from_address: + return { + "status": "paused", + "provider": "mail.notificationDelivery", + "error": ( + "Notification email requires a Mail profile and sender " + "selected by tenant policy." + ), + } + try: + transport = campaign_profile_delivery_summary( + session, + tenant_id=request.tenant_id, + profile_id=profile_id, + smtp_server_id=request.smtp_server_id, + smtp_credential_id=request.smtp_credential_id, + ) + except MailProfileError: + return { + "status": "paused", + "provider": "mail.notificationDelivery", + "error": ( + "The selected notification Mail profile is unavailable or " + "blocked by effective policy." + ), + } + if not transport.get("smtp_available"): + return { + "status": "paused", + "provider": "mail.notificationDelivery", + "error": "The selected notification Mail profile has no usable SMTP transport.", + } + + message = EmailMessage() + message["Date"] = formatdate(localtime=True) + message["Message-ID"] = make_msgid() + message["Subject"] = request.subject + message["From"] = from_address + message["To"] = request.recipient + message.set_content(request.body_text) + if request.body_html: + message.add_alternative(request.body_html, subtype="html") + + from govoplan_mail.backend.delivery_outbox import submit_delivery_command + + command = submit_delivery_command( + session, + tenant_id=request.tenant_id, + command_type="notification", + source_module="notifications", + source_resource_type="notification", + source_resource_id=request.notification_id, + source_version_id=None, + idempotency_key=f"notification:{request.notification_id}", + profile_id=profile_id, + smtp_server_id=( + str(transport.get("smtp_server_id")) + if transport.get("smtp_server_id") + else request.smtp_server_id + ), + smtp_credential_id=( + str(transport.get("smtp_credential_id")) + if transport.get("smtp_credential_id") + else request.smtp_credential_id + ), + message_bytes=bytes(message), + envelope_from=from_address, + envelope_recipients=[request.recipient], + from_header=from_address, + expected_smtp_transport_revision=str( + transport["smtp_transport_revision"] + ), + ) + return { + "status": "accepted", + "provider": "mail.delivery_outbox", + "external_message_id": command["id"], + "delivery_status": command["status"], + "duplicate": bool(command.get("duplicate")), + } + + +def notification_delivery_capability( + context: ModuleContext, +) -> MailNotificationDeliveryCapability: + configure_runtime(settings=context.settings) + return MailNotificationDeliveryCapability() diff --git a/src/govoplan_mail/backend/delivery_outbox.py b/src/govoplan_mail/backend/delivery_outbox.py index 742fd27..8fbe6a0 100644 --- a/src/govoplan_mail/backend/delivery_outbox.py +++ b/src/govoplan_mail/backend/delivery_outbox.py @@ -7,7 +7,7 @@ from collections import Counter from datetime import datetime, timedelta, timezone from typing import Any -from sqlalchemy import and_, or_, select +from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index 5fd4e96..04b30cc 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -6,7 +6,10 @@ from pathlib import Path from sqlalchemy import inspect from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER -from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX +from govoplan_core.core.mail import ( + CAPABILITY_MAIL_DELIVERY_OUTBOX, + CAPABILITY_MAIL_NOTIFICATION_DELIVERY, +) from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import ( DocumentationCondition, @@ -180,6 +183,7 @@ manifest = ModuleManifest( ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"), ModuleInterfaceProvider(name="mail.delivery_commands", version="0.1.0"), ModuleInterfaceProvider(name="mail.delivery_outbox", version="0.1.0"), + ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"), ), requires_interfaces=( ModuleInterfaceRequirement( @@ -253,6 +257,10 @@ manifest = ModuleManifest( "govoplan_mail.backend.capabilities", fromlist=["delivery_outbox_capability"], ).delivery_outbox_capability(context), + CAPABILITY_MAIL_NOTIFICATION_DELIVERY: lambda context: __import__( + "govoplan_mail.backend.capabilities", + fromlist=["notification_delivery_capability"], + ).notification_delivery_capability(context), }, documentation=( DocumentationTopic( diff --git a/tests/test_notification_delivery_capability.py b/tests/test_notification_delivery_capability.py new file mode 100644 index 0000000..ea74fa3 --- /dev/null +++ b/tests/test_notification_delivery_capability.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import unittest +from email import message_from_bytes +from unittest.mock import ANY, patch + +from govoplan_core.core.mail import NotificationMailDeliveryRequest +from govoplan_mail.backend.capabilities import MailNotificationDeliveryCapability + + +class MailNotificationDeliveryCapabilityTests(unittest.TestCase): + def test_missing_policy_selection_pauses_without_transport_effect(self) -> None: + capability = MailNotificationDeliveryCapability() + + with patch( + "govoplan_mail.backend.delivery_outbox.submit_delivery_command" + ) as submit: + result = capability.submit_notification_mail( + object(), + NotificationMailDeliveryRequest( + tenant_id="tenant-1", + notification_id="notification-1", + recipient="person@example.test", + subject="Subject", + body_text="Body", + ), + ) + + self.assertEqual(result["status"], "paused") + submit.assert_not_called() + + def test_notification_is_submitted_to_durable_mail_outbox(self) -> None: + capability = MailNotificationDeliveryCapability() + transport = { + "smtp_available": True, + "smtp_transport_revision": "revision-1", + "smtp_server_id": "server-1", + "smtp_credential_id": "credential-1", + } + command = { + "id": "command-1", + "status": "pending", + "duplicate": False, + } + + with ( + patch( + "govoplan_mail.backend.capabilities.campaign_profile_delivery_summary", + return_value=transport, + ) as summary, + patch( + "govoplan_mail.backend.delivery_outbox.submit_delivery_command", + return_value=command, + ) as submit, + ): + result = capability.submit_notification_mail( + object(), + NotificationMailDeliveryRequest( + tenant_id="tenant-1", + notification_id="notification-1", + recipient="person@example.test", + subject="Subject", + body_text="Body", + body_html="
Body
", + mail_profile_id="profile-1", + from_address="notifications@example.test", + ), + ) + + self.assertEqual(result["status"], "accepted") + self.assertEqual(result["external_message_id"], "command-1") + summary.assert_called_once_with( + ANY, + tenant_id="tenant-1", + profile_id="profile-1", + smtp_server_id=None, + smtp_credential_id=None, + ) + payload = submit.call_args.kwargs + self.assertEqual(payload["idempotency_key"], "notification:notification-1") + self.assertEqual(payload["profile_id"], "profile-1") + self.assertEqual(payload["smtp_server_id"], "server-1") + self.assertEqual(payload["smtp_credential_id"], "credential-1") + message = message_from_bytes(payload["message_bytes"]) + self.assertEqual(message["From"], "notifications@example.test") + self.assertEqual(message["To"], "person@example.test") + self.assertEqual(message["Subject"], "Subject") + + +if __name__ == "__main__": + unittest.main()