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:
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Apprise alerting**: New `NotificationService` using [Apprise](https://github.com/caronc/apprise) for multi-channel push notifications (Telegram, Slack, Discord, webhooks, and 80+ other services via a single URL scheme).
|
||||||
|
- `send_user_notification` — sends to all enabled per-user Apprise channels on processing errors or failures.
|
||||||
|
- `send_admin_notification` — sends to all enabled admin-wide channels for system events.
|
||||||
|
- `test_notification` — validates an Apprise URL by dispatching a test message.
|
||||||
|
- **`NotificationConfig` model**: Added `name` (friendly label) and `apprise_url` (nullable Apprise URL) columns.
|
||||||
|
- **`AdminNotificationConfig` model**: New table (`admin_notification_configs`) for system-wide admin alert channels with `name`, `apprise_url`, `is_enabled`, `notify_on_errors`, `notify_on_system_events`, and `description` fields.
|
||||||
|
- **Notifications API** (`/api/v1/notifications`): Full CRUD endpoints (GET/POST/PUT/DELETE) plus a `/test` endpoint for user notification configs.
|
||||||
|
- **Admin Notifications API** (`/api/v1/admin/notifications`): Full CRUD + `/test` endpoints for admin notification configs, superuser-only.
|
||||||
|
- **Task integration**: `process_mail_account` now calls `send_user_notification` on Gmail credential revocation, per-email forwarding failures, and unhandled processing exceptions.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `NotificationConfigBase` schema: `name` is now a required field; `apprise_url` is an optional field; `config` (channel-specific JSON) is now optional with a default of `{}` (previously required). Existing clients must be updated to supply `name`.
|
||||||
|
|
||||||
|
|
||||||
- **Prometheus metrics** (`/metrics` endpoint on the FastAPI backend, scraped every 15 s):
|
- **Prometheus metrics** (`/metrics` endpoint on the FastAPI backend, scraped every 15 s):
|
||||||
- **HTTP layer** — `http_requests_total` (counter, labelled `method`/`endpoint`/`status_code`) and `http_request_duration_seconds` (histogram). Path segments that are numeric IDs are normalised to `{id}` to avoid label-set explosion.
|
- **HTTP layer** — `http_requests_total` (counter, labelled `method`/`endpoint`/`status_code`) and `http_request_duration_seconds` (histogram). Path segments that are numeric IDs are normalised to `{id}` to avoid label-set explosion.
|
||||||
- **Mail processing** — `mail_processing_runs_total` (counter, by `status`: `completed` / `partial_failure` / `failed`), `mail_processing_emails_total` (counter, by `operation`: `fetched` / `forwarded` / `failed`), `mail_processing_duration_seconds` (histogram), `active_mail_accounts_total` (gauge — set each scheduler cycle).
|
- **Mail processing** — `mail_processing_runs_total` (counter, by `status`: `completed` / `partial_failure` / `failed`), `mail_processing_emails_total` (counter, by `operation`: `fetched` / `forwarded` / `failed`), `mail_processing_duration_seconds` (histogram), `active_mail_accounts_total` (gauge — set each scheduler cycle).
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.models.database_models import (
|
|||||||
ProcessingRun,
|
ProcessingRun,
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionTier,
|
SubscriptionTier,
|
||||||
|
AdminNotificationConfig,
|
||||||
)
|
)
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
AdminUserListResponse,
|
AdminUserListResponse,
|
||||||
@@ -21,7 +22,13 @@ from app.models.schemas import (
|
|||||||
SubscriptionPlanResponse,
|
SubscriptionPlanResponse,
|
||||||
SubscriptionPlanCreate,
|
SubscriptionPlanCreate,
|
||||||
SubscriptionPlanUpdate,
|
SubscriptionPlanUpdate,
|
||||||
|
AdminNotificationConfigCreate,
|
||||||
|
AdminNotificationConfigUpdate,
|
||||||
|
AdminNotificationConfigResponse,
|
||||||
|
NotificationTestRequest,
|
||||||
|
NotificationTestResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.notification_service import test_notification
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -282,3 +289,114 @@ async def delete_plan(
|
|||||||
)
|
)
|
||||||
await db.delete(plan)
|
await db.delete(plan)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin notification config management ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications", response_model=List[AdminNotificationConfigResponse])
|
||||||
|
async def list_admin_notification_configs(
|
||||||
|
current_user: User = Depends(get_current_superuser),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all admin notification configurations (admin only)"""
|
||||||
|
result = await db.execute(select(AdminNotificationConfig))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/notifications",
|
||||||
|
response_model=AdminNotificationConfigResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_admin_notification_config(
|
||||||
|
config_in: AdminNotificationConfigCreate,
|
||||||
|
current_user: User = Depends(get_current_superuser),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new admin notification configuration (admin only)"""
|
||||||
|
config = AdminNotificationConfig(**config_in.dict())
|
||||||
|
db.add(config)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
|
||||||
|
)
|
||||||
|
async def get_admin_notification_config(
|
||||||
|
config_id: int,
|
||||||
|
current_user: User = Depends(get_current_superuser),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a specific admin notification configuration (admin only)"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Admin notification config not found",
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/notifications/{config_id}", response_model=AdminNotificationConfigResponse
|
||||||
|
)
|
||||||
|
async def update_admin_notification_config(
|
||||||
|
config_id: int,
|
||||||
|
config_in: AdminNotificationConfigUpdate,
|
||||||
|
current_user: User = Depends(get_current_superuser),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an admin notification configuration (admin only)"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Admin notification config not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = config_in.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(config, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/notifications/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_admin_notification_config(
|
||||||
|
config_id: int,
|
||||||
|
current_user: User = Depends(get_current_superuser),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete an admin notification configuration (admin only)"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminNotificationConfig).where(AdminNotificationConfig.id == config_id)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Admin notification config not found",
|
||||||
|
)
|
||||||
|
await db.delete(config)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/test", response_model=NotificationTestResponse)
|
||||||
|
async def test_admin_notification_config(
|
||||||
|
request: NotificationTestRequest,
|
||||||
|
current_user: User = Depends(get_current_superuser),
|
||||||
|
):
|
||||||
|
"""Test an admin notification channel by sending a test message (admin only)"""
|
||||||
|
success, message = await test_notification(request.apprise_url)
|
||||||
|
return NotificationTestResponse(success=success, message=message)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Notification configuration endpoints"""
|
"""Notification configuration endpoints"""
|
||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -10,8 +10,12 @@ from app.core.deps import get_current_active_user
|
|||||||
from app.models.database_models import User, NotificationConfig
|
from app.models.database_models import User, NotificationConfig
|
||||||
from app.models.schemas import (
|
from app.models.schemas import (
|
||||||
NotificationConfigCreate,
|
NotificationConfigCreate,
|
||||||
|
NotificationConfigUpdate,
|
||||||
NotificationConfigResponse,
|
NotificationConfigResponse,
|
||||||
|
NotificationTestRequest,
|
||||||
|
NotificationTestResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.notification_service import test_notification
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -24,7 +28,7 @@ async def create_notification_config(
|
|||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Create notification configuration"""
|
"""Create a new notification configuration"""
|
||||||
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
|
config = NotificationConfig(user_id=current_user.id, **config_in.dict())
|
||||||
db.add(config)
|
db.add(config)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -37,8 +41,94 @@ async def list_notification_configs(
|
|||||||
current_user: User = Depends(get_current_active_user),
|
current_user: User = Depends(get_current_active_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""List all notification configurations"""
|
"""List all notification configurations for the current user"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(NotificationConfig).where(NotificationConfig.user_id == current_user.id)
|
select(NotificationConfig).where(NotificationConfig.user_id == current_user.id)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{config_id}", response_model=NotificationConfigResponse)
|
||||||
|
async def get_notification_config(
|
||||||
|
config_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a specific notification configuration"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(NotificationConfig).where(
|
||||||
|
NotificationConfig.id == config_id,
|
||||||
|
NotificationConfig.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification config not found",
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{config_id}", response_model=NotificationConfigResponse)
|
||||||
|
async def update_notification_config(
|
||||||
|
config_id: int,
|
||||||
|
config_in: NotificationConfigUpdate,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a notification configuration"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(NotificationConfig).where(
|
||||||
|
NotificationConfig.id == config_id,
|
||||||
|
NotificationConfig.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification config not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = config_in.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(config, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_notification_config(
|
||||||
|
config_id: int,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a notification configuration"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(NotificationConfig).where(
|
||||||
|
NotificationConfig.id == config_id,
|
||||||
|
NotificationConfig.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Notification config not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.delete(config)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test", response_model=NotificationTestResponse)
|
||||||
|
async def test_notification_config(
|
||||||
|
request: NotificationTestRequest,
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
"""Test a notification channel by sending a test message"""
|
||||||
|
success, message = await test_notification(request.apprise_url)
|
||||||
|
return NotificationTestResponse(success=success, message=message)
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ class NotificationConfig(Base):
|
|||||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
name = Column(String(255), nullable=False, default="My Notification")
|
||||||
|
apprise_url = Column(
|
||||||
|
Text, nullable=True
|
||||||
|
) # The Apprise URL e.g. tgram://token/chatid
|
||||||
|
|
||||||
# Channel details
|
# Channel details
|
||||||
channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False)
|
channel: Column[str] = Column(SQLEnum(NotificationChannel), nullable=False)
|
||||||
is_enabled = Column(Boolean, default=True)
|
is_enabled = Column(Boolean, default=True)
|
||||||
@@ -590,3 +595,29 @@ class AppSetting(Base):
|
|||||||
onupdate=lambda: datetime.now(timezone.utc),
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminNotificationConfig(Base):
|
||||||
|
"""System-wide admin notification channels"""
|
||||||
|
|
||||||
|
__tablename__ = "admin_notification_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
apprise_url = Column(Text, nullable=False)
|
||||||
|
is_enabled = Column(Boolean, default=True)
|
||||||
|
notify_on_errors = Column(Boolean, default=True)
|
||||||
|
notify_on_system_events = Column(Boolean, default=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -219,9 +219,13 @@ class ProcessingLogResponse(BaseModel):
|
|||||||
|
|
||||||
# Notification Config Schemas
|
# Notification Config Schemas
|
||||||
class NotificationConfigBase(BaseModel):
|
class NotificationConfigBase(BaseModel):
|
||||||
|
name: str = Field(
|
||||||
|
..., max_length=255, description="Friendly name for this notification channel"
|
||||||
|
)
|
||||||
channel: NotificationChannel
|
channel: NotificationChannel
|
||||||
|
apprise_url: Optional[str] = Field(None, description="Apprise notification URL")
|
||||||
is_enabled: bool = True
|
is_enabled: bool = True
|
||||||
config: Dict[str, Any]
|
config: Dict[str, Any] = Field(default_factory=dict)
|
||||||
notify_on_errors: bool = True
|
notify_on_errors: bool = True
|
||||||
notify_on_success: bool = False
|
notify_on_success: bool = False
|
||||||
notify_threshold: int = Field(default=3, gt=0, le=100)
|
notify_threshold: int = Field(default=3, gt=0, le=100)
|
||||||
@@ -232,6 +236,9 @@ class NotificationConfigCreate(NotificationConfigBase):
|
|||||||
|
|
||||||
|
|
||||||
class NotificationConfigUpdate(BaseModel):
|
class NotificationConfigUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, max_length=255)
|
||||||
|
channel: Optional[NotificationChannel] = None
|
||||||
|
apprise_url: Optional[str] = None
|
||||||
is_enabled: Optional[bool] = None
|
is_enabled: Optional[bool] = None
|
||||||
config: Optional[Dict[str, Any]] = None
|
config: Optional[Dict[str, Any]] = None
|
||||||
notify_on_errors: Optional[bool] = None
|
notify_on_errors: Optional[bool] = None
|
||||||
@@ -248,6 +255,46 @@ class NotificationConfigResponse(NotificationConfigBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTestRequest(BaseModel):
|
||||||
|
apprise_url: str = Field(..., description="Apprise URL to test")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationTestResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
# Admin Notification Config Schemas
|
||||||
|
class AdminNotificationConfigBase(BaseModel):
|
||||||
|
name: str = Field(..., max_length=255)
|
||||||
|
apprise_url: str = Field(..., description="Apprise notification URL")
|
||||||
|
is_enabled: bool = True
|
||||||
|
notify_on_errors: bool = True
|
||||||
|
notify_on_system_events: bool = True
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminNotificationConfigCreate(AdminNotificationConfigBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AdminNotificationConfigUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, max_length=255)
|
||||||
|
apprise_url: Optional[str] = None
|
||||||
|
is_enabled: Optional[bool] = None
|
||||||
|
notify_on_errors: Optional[bool] = None
|
||||||
|
notify_on_system_events: Optional[bool] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminNotificationConfigResponse(AdminNotificationConfigBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# Subscription Schemas
|
# Subscription Schemas
|
||||||
class SubscriptionPlanResponse(BaseModel):
|
class SubscriptionPlanResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -33,6 +33,7 @@ from app.models.database_models import (
|
|||||||
from app.services.mail_processor import MailProcessor
|
from app.services.mail_processor import MailProcessor
|
||||||
from app.services.gmail_service import GmailService
|
from app.services.gmail_service import GmailService
|
||||||
from app.services.config_service import ConfigService
|
from app.services.config_service import ConfigService
|
||||||
|
from app.services.notification_service import send_user_notification
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from sqlalchemy import select, delete
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
@@ -239,6 +240,18 @@ async def process_mail_account(account_id: int):
|
|||||||
f"Gmail credentials revoked for user {account.user_id}. "
|
f"Gmail credentials revoked for user {account.user_id}. "
|
||||||
"User must re-authorise."
|
"User must re-authorise."
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await send_user_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=account.user_id,
|
||||||
|
title="InboxRescue: Gmail Authorization Expired",
|
||||||
|
body=f"Your Gmail credentials for account '{account.name}' have been revoked. Please re-authorize Gmail access in Settings.",
|
||||||
|
notify_on_error=True,
|
||||||
|
)
|
||||||
|
except Exception as notify_exc:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to send revocation notification: {notify_exc}"
|
||||||
|
)
|
||||||
logger.error(f"Error delivering email: {e}")
|
logger.error(f"Error delivering email: {e}")
|
||||||
emails_failed += 1
|
emails_failed += 1
|
||||||
|
|
||||||
@@ -286,6 +299,16 @@ async def process_mail_account(account_id: int):
|
|||||||
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
account.status = AccountStatus.ERROR # type: ignore[assignment]
|
||||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment]
|
account.last_error_message = f"{emails_failed} emails failed to forward" # type: ignore[assignment]
|
||||||
|
try:
|
||||||
|
await send_user_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=account.user_id,
|
||||||
|
title="InboxRescue: Mail Forwarding Failures",
|
||||||
|
body=f"Mail account '{account.name}': {emails_failed} email(s) failed to forward.",
|
||||||
|
notify_on_error=True,
|
||||||
|
)
|
||||||
|
except Exception as notify_exc:
|
||||||
|
logger.warning(f"Failed to send notification: {notify_exc}")
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -338,6 +361,20 @@ async def process_mail_account(account_id: int):
|
|||||||
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
account.last_error_at = datetime.now(timezone.utc) # type: ignore[assignment]
|
||||||
account.last_error_message = str(e) # type: ignore[assignment]
|
account.last_error_message = str(e) # type: ignore[assignment]
|
||||||
|
|
||||||
|
# Notify user about the error
|
||||||
|
try:
|
||||||
|
await send_user_notification(
|
||||||
|
db=db,
|
||||||
|
user_id=account.user_id,
|
||||||
|
title="InboxRescue: Mail Processing Error",
|
||||||
|
body=f"Error processing mail account '{account.name}': {e}",
|
||||||
|
notify_on_error=True,
|
||||||
|
)
|
||||||
|
except Exception as notify_exc:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to send error notification: {notify_exc}"
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user