feat: provide governed notification email delivery
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
91
tests/test_notification_delivery_capability.py
Normal file
91
tests/test_notification_delivery_capability.py
Normal file
@@ -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="<p>Body</p>",
|
||||
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()
|
||||
Reference in New Issue
Block a user