feat: route email notifications through mail capability

This commit is contained in:
2026-07-30 17:42:07 +02:00
parent e32ba3663b
commit 8153a1de45
9 changed files with 311 additions and 18 deletions
+24
View File
@@ -0,0 +1,24 @@
# Email Notification Delivery
In-app notifications are the canonical notification channel. They remain
available when no email implementation is installed.
Production email delivery is an optional integration with the
`mail.notificationDelivery` capability:
- Notifications owns recipient intent, notification preferences, content, and
notification status.
- Mail owns server profiles, credentials, queue persistence, retry policy, and
transport outcomes.
- An accepted handoff is recorded as `accepted`; it does not claim that the
remote SMTP server has delivered the message.
- If Mail or a usable profile is unavailable, delivery is recorded as
`paused`. The notification is retained and can be retried after the
capability becomes available.
- Transport failures are recorded as `failed` with an attempt record and do
not remove the in-app notification.
- File-based EML delivery is development-only and must not be used as a
production fallback.
The capability contract deliberately passes references to recipient, tenant,
and profile context without exposing or duplicating Mail credentials.
@@ -10,6 +10,7 @@ from govoplan_notifications.backend.service import deliver_notification, deliver
class SqlNotificationDispatchProvider(NotificationDispatchProvider): class SqlNotificationDispatchProvider(NotificationDispatchProvider):
def __init__(self, context: ModuleContext) -> None: def __init__(self, context: ModuleContext) -> None:
self._settings = context.settings self._settings = context.settings
self._registry = context.registry
def enqueue_notification( def enqueue_notification(
self, self,
@@ -22,7 +23,12 @@ class SqlNotificationDispatchProvider(NotificationDispatchProvider):
return notification_response(notification) return notification_response(notification)
def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]: def deliver_notification(self, session: object, *, notification_id: str) -> Mapping[str, object]:
notification = deliver_notification(session, notification_id=notification_id, settings=self._settings) # type: ignore[arg-type] notification = deliver_notification(
session,
notification_id=notification_id,
settings=self._settings,
registry=self._registry,
) # type: ignore[arg-type]
return notification_response(notification) return notification_response(notification)
def deliver_pending( def deliver_pending(
@@ -32,7 +38,13 @@ class SqlNotificationDispatchProvider(NotificationDispatchProvider):
tenant_id: str | None = None, tenant_id: str | None = None,
limit: int = 50, limit: int = 50,
) -> Mapping[str, object]: ) -> Mapping[str, object]:
return deliver_pending(session, tenant_id=tenant_id, limit=limit, settings=self._settings) # type: ignore[arg-type] return deliver_pending(
session,
tenant_id=tenant_id,
limit=limit,
settings=self._settings,
registry=self._registry,
) # type: ignore[arg-type]
def dispatch_capability(context: ModuleContext) -> SqlNotificationDispatchProvider: def dispatch_capability(context: ModuleContext) -> SqlNotificationDispatchProvider:
+19 -2
View File
@@ -6,7 +6,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_core.settings import settings as core_settings
from govoplan_notifications.backend.manifest import ADMIN_SCOPE, DISPATCH_SCOPE, READ_SCOPE, WRITE_SCOPE from govoplan_notifications.backend.manifest import ADMIN_SCOPE, DISPATCH_SCOPE, READ_SCOPE, WRITE_SCOPE
from govoplan_notifications.backend.schemas import ( from govoplan_notifications.backend.schemas import (
NotificationCreateRequest, NotificationCreateRequest,
@@ -169,7 +171,13 @@ def api_deliver_pending(
_require_scope(principal, DISPATCH_SCOPE) _require_scope(principal, DISPATCH_SCOPE)
if payload.tenant_id is not None: if payload.tenant_id is not None:
_require_scope(principal, ADMIN_SCOPE) _require_scope(principal, ADMIN_SCOPE)
result = deliver_pending(session, tenant_id=payload.tenant_id or principal.tenant_id, limit=payload.limit) result = deliver_pending(
session,
tenant_id=payload.tenant_id or principal.tenant_id,
limit=payload.limit,
settings=core_settings,
registry=get_registry(),
)
session.commit() session.commit()
return NotificationDeliveryResultResponse.model_validate(result) return NotificationDeliveryResultResponse.model_validate(result)
@@ -227,17 +235,26 @@ def api_deliver_notification(
_require_scope(principal, DISPATCH_SCOPE) _require_scope(principal, DISPATCH_SCOPE)
try: try:
get_notification(session, tenant_id=principal.tenant_id, notification_id=notification_id) get_notification(session, tenant_id=principal.tenant_id, notification_id=notification_id)
notification = deliver_notification(session, notification_id=notification_id) notification = deliver_notification(
session,
notification_id=notification_id,
settings=core_settings,
registry=get_registry(),
)
except NotificationError as exc: except NotificationError as exc:
raise _notification_http_error(exc) from exc raise _notification_http_error(exc) from exc
session.commit() session.commit()
sent = 1 if notification.status == "sent" else 0 sent = 1 if notification.status == "sent" else 0
failed = 1 if notification.status == "failed" else 0 failed = 1 if notification.status == "failed" else 0
skipped = 1 if notification.status == "skipped" else 0 skipped = 1 if notification.status == "skipped" else 0
accepted = 1 if notification.status == "accepted" else 0
paused = 1 if notification.status == "paused" else 0
return NotificationDeliveryResultResponse( return NotificationDeliveryResultResponse(
notification=_response(notification), notification=_response(notification),
processed=1, processed=1,
sent=sent, sent=sent,
accepted=accepted,
paused=paused,
failed=failed, failed=failed,
skipped=skipped, skipped=skipped,
errors=[notification.last_error] if notification.last_error else [], errors=[notification.last_error] if notification.last_error else [],
+13 -1
View File
@@ -6,7 +6,17 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
NotificationStatus = Literal["pending", "queued", "sending", "sent", "failed", "skipped", "cancelled"] NotificationStatus = Literal[
"pending",
"queued",
"sending",
"accepted",
"paused",
"sent",
"failed",
"skipped",
"cancelled",
]
def normalize_notification_action_url(value: str | None) -> str | None: def normalize_notification_action_url(value: str | None) -> str | None:
@@ -154,6 +164,8 @@ class NotificationDeliveryResultResponse(BaseModel):
notification: NotificationResponse | None = None notification: NotificationResponse | None = None
processed: int = 0 processed: int = 0
sent: int = 0 sent: int = 0
accepted: int = 0
paused: int = 0
failed: int = 0 failed: int = 0
skipped: int = 0 skipped: int = 0
errors: list[str] = Field(default_factory=list) errors: list[str] = Field(default_factory=list)
+109 -9
View File
@@ -9,7 +9,12 @@ from typing import Any
from sqlalchemy import and_, case, event, func, or_ from sqlalchemy import and_, case, event, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.mail import (
NotificationMailDeliveryRequest,
notification_mail_delivery_provider,
)
from govoplan_core.core.notifications import NotificationDispatchRequest from govoplan_core.core.notifications import NotificationDispatchRequest
from govoplan_core.core.runtime import get_registry
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference
from govoplan_notifications.backend.schemas import ( from govoplan_notifications.backend.schemas import (
@@ -25,6 +30,7 @@ class NotificationError(ValueError):
PENDING_STATUSES = {"pending", "queued", "failed"} PENDING_STATUSES = {"pending", "queued", "failed"}
SUMMARY_PENDING_STATUSES = PENDING_STATUSES | {"accepted", "paused", "sending"}
_AFTER_COMMIT_DELIVERY_IDS = "govoplan_notifications_after_commit_delivery_ids" _AFTER_COMMIT_DELIVERY_IDS = "govoplan_notifications_after_commit_delivery_ids"
@@ -232,7 +238,7 @@ def notification_summary(
and_( and_(
active, active,
NotificationMessage.status.in_( NotificationMessage.status.in_(
sorted(PENDING_STATUSES) sorted(SUMMARY_PENDING_STATUSES)
), ),
), ),
1, 1,
@@ -329,6 +335,7 @@ def deliver_notification(
*, *,
notification_id: str, notification_id: str,
settings: object | None = None, settings: object | None = None,
registry: object | None = None,
) -> NotificationMessage: ) -> NotificationMessage:
# An explicit same-transaction delivery supersedes the deferred Celery # An explicit same-transaction delivery supersedes the deferred Celery
# handoff registered when the row was created. # handoff registered when the row was created.
@@ -336,7 +343,7 @@ def deliver_notification(
notification = session.get(NotificationMessage, notification_id) notification = session.get(NotificationMessage, notification_id)
if notification is None or notification.deleted_at is not None: if notification is None or notification.deleted_at is not None:
raise NotificationError("Notification not found") raise NotificationError("Notification not found")
if notification.status in {"sent", "skipped", "cancelled"}: if notification.status in {"accepted", "sent", "skipped", "cancelled"}:
return notification return notification
if notification.not_before_at is not None and response_datetime(notification.not_before_at) > _now(): if notification.not_before_at is not None and response_datetime(notification.not_before_at) > _now():
notification.status = "queued" notification.status = "queued"
@@ -345,7 +352,12 @@ def deliver_notification(
attempt = _start_attempt(notification) attempt = _start_attempt(notification)
try: try:
result = _deliver_by_channel(notification, settings=settings) result = _deliver_by_channel(
session,
notification,
settings=settings,
registry=registry if registry is not None else get_registry(),
)
except Exception as exc: # noqa: BLE001 - persisted as delivery failure. except Exception as exc: # noqa: BLE001 - persisted as delivery failure.
_finish_attempt(attempt, status="failed", error=str(exc)) _finish_attempt(attempt, status="failed", error=str(exc))
notification.status = "failed" notification.status = "failed"
@@ -353,7 +365,13 @@ def deliver_notification(
notification.last_error = str(exc) notification.last_error = str(exc)
session.flush() session.flush()
return notification return notification
_finish_attempt(attempt, status=result["status"], provider=result.get("provider"), details=result) _finish_attempt(
attempt,
status=result["status"],
provider=result.get("provider"),
error=result.get("error"),
details=result,
)
notification.status = result["status"] notification.status = result["status"]
notification.sent_at = _now() if result["status"] == "sent" else notification.sent_at notification.sent_at = _now() if result["status"] == "sent" else notification.sent_at
notification.failed_at = _now() if result["status"] == "failed" else notification.failed_at notification.failed_at = _now() if result["status"] == "failed" else notification.failed_at
@@ -369,6 +387,7 @@ def deliver_pending(
tenant_id: str | None = None, tenant_id: str | None = None,
limit: int = 50, limit: int = 50,
settings: object | None = None, settings: object | None = None,
registry: object | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
now = _now() now = _now()
query = session.query(NotificationMessage).filter( query = session.query(NotificationMessage).filter(
@@ -379,12 +398,31 @@ def deliver_pending(
if tenant_id: if tenant_id:
query = query.filter(NotificationMessage.tenant_id == tenant_id) query = query.filter(NotificationMessage.tenant_id == tenant_id)
notifications = query.order_by(NotificationMessage.priority.desc(), NotificationMessage.created_at.asc()).limit(limit).all() notifications = query.order_by(NotificationMessage.priority.desc(), NotificationMessage.created_at.asc()).limit(limit).all()
result = {"processed": 0, "sent": 0, "failed": 0, "skipped": 0, "errors": []} result = {
"processed": 0,
"sent": 0,
"accepted": 0,
"paused": 0,
"failed": 0,
"skipped": 0,
"errors": [],
}
for notification in notifications: for notification in notifications:
result["processed"] += 1 result["processed"] += 1
delivered = deliver_notification(session, notification_id=notification.id, settings=settings) delivered = deliver_notification(
session,
notification_id=notification.id,
settings=settings,
registry=registry,
)
if delivered.status == "sent": if delivered.status == "sent":
result["sent"] += 1 result["sent"] += 1
elif delivered.status == "accepted":
result["accepted"] += 1
elif delivered.status == "paused":
result["paused"] += 1
if delivered.last_error:
result["errors"].append(delivered.last_error)
elif delivered.status == "skipped": elif delivered.status == "skipped":
result["skipped"] += 1 result["skipped"] += 1
elif delivered.status == "failed": elif delivered.status == "failed":
@@ -496,17 +534,79 @@ def _finish_attempt(
attempt.external_message_id = str(details["external_message_id"]) attempt.external_message_id = str(details["external_message_id"])
def _deliver_by_channel(notification: NotificationMessage, *, settings: object | None) -> dict[str, Any]: def _deliver_by_channel(
session: Session,
notification: NotificationMessage,
*,
settings: object | None,
registry: object | None,
) -> dict[str, Any]:
if notification.channel == "inbox": if notification.channel == "inbox":
return {"status": "sent", "provider": "inbox", "external_message_id": notification.id} return {"status": "sent", "provider": "inbox", "external_message_id": notification.id}
if notification.channel == "mail": if notification.channel == "mail":
if not notification.recipient: if not notification.recipient:
return {"status": "skipped", "provider": "mail", "error": "Mail notification has no recipient address"} return {"status": "skipped", "provider": "mail", "error": "Mail notification has no recipient address"}
path = _write_local_mail(notification, settings=settings) provider = notification_mail_delivery_provider(registry)
return {"status": "sent", "provider": "local_file_mail", "external_message_id": str(path), "path": str(path)} if provider is not None:
mail_settings = _notification_mail_settings(notification)
return dict(
provider.submit_notification_mail(
session,
NotificationMailDeliveryRequest(
tenant_id=notification.tenant_id,
notification_id=notification.id,
recipient=notification.recipient,
subject=notification.subject or f"Notification: {notification.event_kind}",
body_text=notification.body_text or notification.subject or notification.event_kind,
body_html=notification.body_html,
action_url=notification.action_url,
mail_profile_id=_optional_text(mail_settings.get("mail_profile_id")),
from_address=_optional_text(mail_settings.get("from_address")),
smtp_server_id=_optional_text(mail_settings.get("smtp_server_id")),
smtp_credential_id=_optional_text(mail_settings.get("smtp_credential_id")),
metadata={
"source_module": notification.source_module,
"event_kind": notification.event_kind,
},
),
)
)
if _development_file_mail_enabled(settings):
path = _write_local_mail(notification, settings=settings)
return {
"status": "sent",
"provider": "local_file_mail",
"external_message_id": str(path),
"path": str(path),
}
return {
"status": "paused",
"provider": "mail",
"error": "Mail-backed notification delivery is unavailable.",
}
return {"status": "skipped", "provider": notification.channel, "error": f"No delivery adapter configured for channel {notification.channel!r}"} return {"status": "skipped", "provider": notification.channel, "error": f"No delivery adapter configured for channel {notification.channel!r}"}
def _notification_mail_settings(
notification: NotificationMessage,
) -> dict[str, object]:
metadata = notification.metadata_ if isinstance(notification.metadata_, dict) else {}
value = metadata.get("mail_delivery")
return dict(value) if isinstance(value, dict) else {}
def _optional_text(value: object) -> str | None:
text = str(value or "").strip()
return text or None
def _development_file_mail_enabled(settings: object | None) -> bool:
if settings is None:
return False
app_env = str(getattr(settings, "app_env", "")).strip().lower()
return app_env in {"dev", "development", "test"}
def _write_local_mail(notification: NotificationMessage, *, settings: object | None) -> Path: def _write_local_mail(notification: NotificationMessage, *, settings: object | None) -> Path:
root = Path(str(getattr(settings, "mock_mailbox_dir", "runtime/mock-mailbox"))) / "notifications" root = Path(str(getattr(settings, "mock_mailbox_dir", "runtime/mock-mailbox"))) / "notifications"
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
+106 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from fastapi import HTTPException from fastapi import HTTPException
@@ -11,6 +12,7 @@ from sqlalchemy.orm import sessionmaker
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.mail import CAPABILITY_MAIL_NOTIFICATION_DELIVERY
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.notifications import NotificationDispatchRequest from govoplan_core.core.notifications import NotificationDispatchRequest
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -28,6 +30,32 @@ from govoplan_notifications.backend.service import (
) )
class _NotificationMailProvider:
def __init__(self) -> None:
self.request = None
def submit_notification_mail(self, session, request):
self.request = request
return {
"status": "accepted",
"provider": "mail.delivery_outbox",
"external_message_id": "mail-command-1",
}
class _CapabilityRegistry:
def __init__(self, provider: object) -> None:
self.provider = provider
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_MAIL_NOTIFICATION_DELIVERY
def require_capability(self, name: str) -> object:
if not self.has_capability(name):
raise LookupError(name)
return self.provider
class NotificationServiceTests(unittest.TestCase): class NotificationServiceTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:") self.engine = create_engine("sqlite:///:memory:")
@@ -290,7 +318,11 @@ class NotificationServiceTests(unittest.TestCase):
def test_mail_delivery_uses_local_file_transport(self) -> None: def test_mail_delivery_uses_local_file_transport(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir, self.Session() as session: with tempfile.TemporaryDirectory() as tmpdir, self.Session() as session:
settings = type("Settings", (), {"mock_mailbox_dir": tmpdir})() settings = type(
"Settings",
(),
{"app_env": "dev", "mock_mailbox_dir": tmpdir},
)()
notification = create_notification( notification = create_notification(
session, session,
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -310,6 +342,79 @@ class NotificationServiceTests(unittest.TestCase):
self.assertEqual(notification.status, "sent") self.assertEqual(notification.status, "sent")
self.assertTrue(notification.external_message_id.endswith(".eml")) self.assertTrue(notification.external_message_id.endswith(".eml"))
def test_production_mail_delivery_pauses_without_mail_capability(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir, self.Session() as session:
notification = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
event_kind="created",
channel="mail",
recipient="person@example.test",
subject="Created",
),
)
result = deliver_pending(
session,
tenant_id="tenant-1",
settings=SimpleNamespace(
app_env="production",
mock_mailbox_dir=tmpdir,
),
registry=object(),
)
self.assertEqual(result["paused"], 1)
self.assertEqual(notification.status, "paused")
self.assertEqual(
notification.last_error,
"Mail-backed notification delivery is unavailable.",
)
self.assertEqual(list(Path(tmpdir).rglob("*.eml")), [])
def test_mail_delivery_uses_optional_mail_capability(self) -> None:
provider = _NotificationMailProvider()
registry = _CapabilityRegistry(provider)
with self.Session() as session:
notification = create_notification(
session,
tenant_id="tenant-1",
payload=NotificationCreateRequest(
source_module="test",
source_resource_type="thing",
event_kind="created",
channel="mail",
recipient="person@example.test",
subject="Created",
body_text="Hello",
metadata={
"mail_delivery": {
"mail_profile_id": "profile-1",
"from_address": "notifications@example.test",
}
},
),
)
result = deliver_pending(
session,
tenant_id="tenant-1",
settings=SimpleNamespace(app_env="production"),
registry=registry,
)
self.assertEqual(result["accepted"], 1)
self.assertEqual(notification.status, "accepted")
self.assertEqual(notification.external_message_id, "mail-command-1")
self.assertEqual(provider.request.mail_profile_id, "profile-1")
self.assertEqual(
provider.request.from_address,
"notifications@example.test",
)
def test_dispatch_capability_enqueues_notification(self) -> None: def test_dispatch_capability_enqueues_notification(self) -> None:
with self.Session() as session: with self.Session() as session:
capability = dispatch_capability(ModuleContext(registry=object(), settings=object())) capability = dispatch_capability(ModuleContext(registry=object(), settings=object()))
+2
View File
@@ -77,6 +77,8 @@ export type NotificationDeliveryResult = {
notification?: NotificationMessage | null; notification?: NotificationMessage | null;
processed: number; processed: number;
sent: number; sent: number;
accepted: number;
paused: number;
failed: number; failed: number;
skipped: number; skipped: number;
errors: string[]; errors: string[];
@@ -4,9 +4,30 @@ import { AdminIconButton, Button, DismissibleAlert, SegmentedControl, SelectionL
import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications"; import { deliverPendingNotifications, listNotifications, updateNotification, type NotificationMessage } from "../../api/notifications";
import { safeNotificationActionUrl } from "../../security/actionUrl"; import { safeNotificationActionUrl } from "../../security/actionUrl";
type StatusFilter = "all" | "pending" | "queued" | "sent" | "failed" | "skipped" | "cancelled"; type StatusFilter =
| "all"
| "pending"
| "queued"
| "sending"
| "accepted"
| "paused"
| "sent"
| "failed"
| "skipped"
| "cancelled";
const statusFilters: StatusFilter[] = ["all", "pending", "queued", "sent", "failed", "skipped", "cancelled"]; const statusFilters: StatusFilter[] = [
"all",
"pending",
"queued",
"sending",
"accepted",
"paused",
"sent",
"failed",
"skipped",
"cancelled"
];
export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { export default function NotificationCenterPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const [notifications, setNotifications] = useState<NotificationMessage[]>([]); const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
@@ -176,7 +176,7 @@ export default function NotificationSettingsPanel({ settings, auth }: { settings
</div> </div>
<ToggleSwitch <ToggleSwitch
label="Email notifications" label="Email notifications"
help="Prepared for mail-backed notification delivery." help="Deliver production email through the optional Mail module. In-app notifications continue to work when Mail is unavailable."
checked={draft.email_enabled} checked={draft.email_enabled}
disabled={loading} disabled={loading}
onChange={(value) => setDraft((current) => ({ ...current, email_enabled: value, email_digest_enabled: value ? current.email_digest_enabled : false }))} onChange={(value) => setDraft((current) => ({ ...current, email_enabled: value, email_digest_enabled: value ? current.email_digest_enabled : false }))}