feat: route email notifications through mail capability
This commit is contained in:
@@ -9,7 +9,12 @@ from typing import Any
|
||||
from sqlalchemy import and_, case, event, func, or_
|
||||
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.runtime import get_registry
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_notifications.backend.db.models import NotificationDeliveryAttempt, NotificationMessage, NotificationPreference
|
||||
from govoplan_notifications.backend.schemas import (
|
||||
@@ -25,6 +30,7 @@ class NotificationError(ValueError):
|
||||
|
||||
|
||||
PENDING_STATUSES = {"pending", "queued", "failed"}
|
||||
SUMMARY_PENDING_STATUSES = PENDING_STATUSES | {"accepted", "paused", "sending"}
|
||||
_AFTER_COMMIT_DELIVERY_IDS = "govoplan_notifications_after_commit_delivery_ids"
|
||||
|
||||
|
||||
@@ -232,7 +238,7 @@ def notification_summary(
|
||||
and_(
|
||||
active,
|
||||
NotificationMessage.status.in_(
|
||||
sorted(PENDING_STATUSES)
|
||||
sorted(SUMMARY_PENDING_STATUSES)
|
||||
),
|
||||
),
|
||||
1,
|
||||
@@ -329,6 +335,7 @@ def deliver_notification(
|
||||
*,
|
||||
notification_id: str,
|
||||
settings: object | None = None,
|
||||
registry: object | None = None,
|
||||
) -> NotificationMessage:
|
||||
# An explicit same-transaction delivery supersedes the deferred Celery
|
||||
# handoff registered when the row was created.
|
||||
@@ -336,7 +343,7 @@ def deliver_notification(
|
||||
notification = session.get(NotificationMessage, notification_id)
|
||||
if notification is None or notification.deleted_at is not None:
|
||||
raise NotificationError("Notification not found")
|
||||
if notification.status in {"sent", "skipped", "cancelled"}:
|
||||
if notification.status in {"accepted", "sent", "skipped", "cancelled"}:
|
||||
return notification
|
||||
if notification.not_before_at is not None and response_datetime(notification.not_before_at) > _now():
|
||||
notification.status = "queued"
|
||||
@@ -345,7 +352,12 @@ def deliver_notification(
|
||||
|
||||
attempt = _start_attempt(notification)
|
||||
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.
|
||||
_finish_attempt(attempt, status="failed", error=str(exc))
|
||||
notification.status = "failed"
|
||||
@@ -353,7 +365,13 @@ def deliver_notification(
|
||||
notification.last_error = str(exc)
|
||||
session.flush()
|
||||
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.sent_at = _now() if result["status"] == "sent" else notification.sent_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,
|
||||
limit: int = 50,
|
||||
settings: object | None = None,
|
||||
registry: object | None = None,
|
||||
) -> dict[str, Any]:
|
||||
now = _now()
|
||||
query = session.query(NotificationMessage).filter(
|
||||
@@ -379,12 +398,31 @@ def deliver_pending(
|
||||
if tenant_id:
|
||||
query = query.filter(NotificationMessage.tenant_id == tenant_id)
|
||||
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:
|
||||
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":
|
||||
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":
|
||||
result["skipped"] += 1
|
||||
elif delivered.status == "failed":
|
||||
@@ -496,17 +534,79 @@ def _finish_attempt(
|
||||
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":
|
||||
return {"status": "sent", "provider": "inbox", "external_message_id": notification.id}
|
||||
if notification.channel == "mail":
|
||||
if not notification.recipient:
|
||||
return {"status": "skipped", "provider": "mail", "error": "Mail notification has no recipient address"}
|
||||
path = _write_local_mail(notification, settings=settings)
|
||||
return {"status": "sent", "provider": "local_file_mail", "external_message_id": str(path), "path": str(path)}
|
||||
provider = notification_mail_delivery_provider(registry)
|
||||
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}"}
|
||||
|
||||
|
||||
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:
|
||||
root = Path(str(getattr(settings, "mock_mailbox_dir", "runtime/mock-mailbox"))) / "notifications"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
Reference in New Issue
Block a user