feat: provide governed notification email delivery
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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