Add Apprise alerting capabilities (backend)

- Add name + apprise_url columns to NotificationConfig model
- Add AdminNotificationConfig model for system-wide alert channels
- Create NotificationService with send_user_notification,
  send_admin_notification, and test_notification helpers
- Replace stub notifications.py with full CRUD + /test endpoint
- Add admin notification config CRUD + /test to admin.py
- Update NotificationConfig schemas: name required, apprise_url
  optional, config optional (default {})
- Add AdminNotificationConfig* schemas
- Integrate send_user_notification into process_mail_account task
  for Gmail revocation, forwarding failures, and exceptions
- Update CHANGELOG.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-26 18:06:05 +00:00
parent a6c95bef4d
commit bbd2febfc1
7 changed files with 484 additions and 4 deletions
@@ -0,0 +1,143 @@
"""
Notification service using Apprise for multi-channel alerting.
Supports:
- User-specific notifications (per-user Apprise URLs)
- Admin-wide system notifications (system-level alerts)
- Test notifications to verify configuration
"""
import logging
import apprise
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.database_models import NotificationConfig, AdminNotificationConfig
logger = logging.getLogger(__name__)
async def send_user_notification(
db: AsyncSession,
user_id: int,
title: str,
body: str,
notify_on_error: bool = True,
) -> int:
"""
Send a notification to all enabled notification channels for a given user.
Args:
db: Database session
user_id: The user to notify
title: Notification title/subject
body: Notification body text
notify_on_error: If True, only sends to channels with notify_on_errors=True
If False, only sends to channels with notify_on_success=True
Returns:
Number of channels notified successfully
"""
result = await db.execute(
select(NotificationConfig).where(
NotificationConfig.user_id == user_id,
NotificationConfig.is_enabled == True, # noqa: E712
NotificationConfig.apprise_url.isnot(None),
)
)
configs = result.scalars().all()
if not configs:
return 0
sent = 0
for config in configs:
if notify_on_error and not config.notify_on_errors:
continue
if not notify_on_error and not config.notify_on_success:
continue
try:
success = await _send_apprise(str(config.apprise_url), title, body)
if success:
sent += 1
except Exception as exc:
logger.warning(
"Failed to send notification via channel %s (user %s): %s",
config.id,
user_id,
exc,
)
return sent
async def send_admin_notification(
db: AsyncSession,
title: str,
body: str,
) -> int:
"""
Send a notification to all enabled admin notification channels.
Returns:
Number of channels notified successfully
"""
result = await db.execute(
select(AdminNotificationConfig).where(
AdminNotificationConfig.is_enabled == True, # noqa: E712
AdminNotificationConfig.notify_on_errors == True, # noqa: E712
)
)
configs = result.scalars().all()
if not configs:
return 0
sent = 0
for config in configs:
try:
success = await _send_apprise(str(config.apprise_url), title, body)
if success:
sent += 1
except Exception as exc:
logger.warning(
"Failed to send admin notification via channel %s: %s",
config.id,
exc,
)
return sent
async def test_notification(apprise_url: str) -> tuple[bool, str]:
"""
Send a test notification to the given Apprise URL.
Returns:
(success, message) tuple
"""
try:
success = await _send_apprise(
apprise_url,
title="InboxRescue: Test Notification",
body="This is a test notification from InboxRescue. Your notification channel is configured correctly!",
)
if success:
return True, "Test notification sent successfully"
return False, "Notification delivery failed (check your Apprise URL)"
except Exception as exc:
return False, f"Error sending test notification: {exc}"
async def _send_apprise(url: str, title: str, body: str) -> bool:
"""Internal helper create an Apprise instance, load the URL, and notify."""
ap = apprise.Apprise()
if not ap.add(url):
logger.warning("Apprise could not parse URL: %s", url[:60])
return False
result = await ap.async_notify(title=title, body=body)
return bool(result)