Files
Juan DenisandClaude Opus 4.8 a661212d50 feat(notifications): queue-backed Notification Bus + email system
Reusable, non-blocking notification layer on top of the Queue Bus:
notify.send(event, to, data) records a Notification + per-channel
NotificationDelivery, queues them, and a daemon consumer delivers via
pluggable channel adapters (in-app, email, chat) inheriting retry,
backoff and dead-letter.

- Jinja2 email system: branded, dark-mode, Outlook-safe base layout +
  per-event templates (security/backup/monitoring/welcome/invitation).
  The legacy inline f-string email was removed and now renders here.
- In-app center API (inbox/unread-count/mark-read) + per-user Socket.IO push.
- Admin delivery log (stats/filter/retry).
- Email providers (SMTP/SendGrid/Postmark/SES/Mailgun) with encrypted
  credentials + test; the email adapter prefers the default provider and
  falls back to the legacy SMTP config; surfaced in ConnectionRegistry.
- NotificationService.send_all() is now a non-blocking shim that routes
  system alerts through the bus. Migrations 023 + 024.

Tests: backend/tests/test_notification_bus.py (20). Full suite: 391 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:57:02 -04:00

50 lines
1.6 KiB
Python

"""Channel adapter registry.
Maps a channel key -> adapter instance. ``register_adapter`` lets a plugin add
a new channel (SMS, web-push, ...) that the consumer can then deliver, exactly
like core channels.
"""
from app.notifications.channels.base import ChannelAdapter, DeliveryResult
from app.notifications.channels.inapp import InAppAdapter
from app.notifications.channels.email import EmailAdapter
from app.notifications.channels.chat import ChatAdapter
# Channels the bus knows how to deliver out of the box.
_REGISTRY = {
'inapp': InAppAdapter(),
'email': EmailAdapter(),
'discord': ChatAdapter('discord'),
'slack': ChatAdapter('slack'),
'telegram': ChatAdapter('telegram'),
'webhook': ChatAdapter('webhook'),
}
# Channels that can be targeted per-user from a user's NotificationPreferences.
# (Slack/generic-webhook are system-wide today, so they're not auto-selected for
# individual recipients — but remain deliverable if a target is supplied.)
PER_USER_CHANNELS = ('inapp', 'email', 'discord', 'telegram')
def register_adapter(adapter):
"""Register (or replace) a channel adapter. ``adapter.key`` is the channel."""
if not getattr(adapter, 'key', None):
raise ValueError('adapter must define a non-empty key')
_REGISTRY[adapter.key] = adapter
return adapter
def get_adapter(channel):
"""Return the adapter for a channel key, or None if unknown."""
return _REGISTRY.get(channel)
def known_channels():
return sorted(_REGISTRY.keys())
__all__ = [
'ChannelAdapter', 'DeliveryResult',
'register_adapter', 'get_adapter', 'known_channels',
'PER_USER_CHANNELS',
]