feat: provide governed notification email delivery

This commit is contained in:
2026-07-30 17:42:07 +02:00
parent b8029c24d6
commit 0cb6719b91
4 changed files with 203 additions and 2 deletions

View File

@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.mail import NotificationMailDeliveryRequest
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_mail.backend.mail_profiles import ( from govoplan_mail.backend.mail_profiles import (
MailProfileError, MailProfileError,
@@ -554,3 +557,102 @@ def delivery_outbox_capability(context: ModuleContext):
configure_runtime(settings=context.settings) configure_runtime(settings=context.settings)
return MailDeliveryOutboxCapability() 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()

View File

@@ -7,7 +7,7 @@ from collections import Counter
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import and_, or_, select from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session

View File

@@ -6,7 +6,10 @@ from pathlib import Path
from sqlalchemy import inspect from sqlalchemy import inspect
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER 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.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationCondition, DocumentationCondition,
@@ -180,6 +183,7 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"), ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
ModuleInterfaceProvider(name="mail.delivery_commands", version="0.1.0"), ModuleInterfaceProvider(name="mail.delivery_commands", version="0.1.0"),
ModuleInterfaceProvider(name="mail.delivery_outbox", version="0.1.0"), ModuleInterfaceProvider(name="mail.delivery_outbox", version="0.1.0"),
ModuleInterfaceProvider(name="mail.notification_delivery", version="0.1.0"),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
@@ -253,6 +257,10 @@ manifest = ModuleManifest(
"govoplan_mail.backend.capabilities", "govoplan_mail.backend.capabilities",
fromlist=["delivery_outbox_capability"], fromlist=["delivery_outbox_capability"],
).delivery_outbox_capability(context), ).delivery_outbox_capability(context),
CAPABILITY_MAIL_NOTIFICATION_DELIVERY: lambda context: __import__(
"govoplan_mail.backend.capabilities",
fromlist=["notification_delivery_capability"],
).notification_delivery_capability(context),
}, },
documentation=( documentation=(
DocumentationTopic( DocumentationTopic(

View 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()