anderson-ufrj
Claude
commited on
Commit
·
389e212
1
Parent(s):
b1d0061
feat(notifications): implement comprehensive notification system
Browse files- Add email service with SMTP support and Jinja2 templates
- Implement webhook service with retry logic and signature verification
- Integrate notification channels (email, webhook, future push support)
- Create notification API endpoints for preferences and history
- Add email templates for various notification types
- Support notification filtering and batch operations
- Implement user preference management
- Add webhook configuration and testing endpoints
The notification system supports multiple channels, template-based emails,
secure webhook delivery, and comprehensive preference management.
Co-Authored-By: Claude <[email protected]>
- src/api/app.py +6 -1
- src/api/routes/__init__.py +2 -2
- src/api/routes/notifications.py +355 -0
- src/models/notification_models.py +233 -0
- src/services/email_service.py +395 -0
- src/services/webhook_service.py +391 -0
- src/templates/email/anomaly_alert.html +94 -0
- src/templates/email/base.html +116 -0
- src/templates/email/investigation_complete.html +75 -0
- src/templates/email/notification.html +39 -0
- src/templates/email/notification.txt +28 -0
src/api/app.py
CHANGED
|
@@ -20,7 +20,7 @@ from fastapi.openapi.utils import get_openapi
|
|
| 20 |
from src.core import get_logger, settings
|
| 21 |
from src.core.exceptions import CidadaoAIError, create_error_response
|
| 22 |
from src.core.audit import audit_logger, AuditEventType, AuditSeverity, AuditContext
|
| 23 |
-
from src.api.routes import investigations, analysis, reports, health, auth, oauth, audit, chat, websocket_chat, batch, graphql, cqrs, resilience, observability, chat_simple, chat_stable, chat_optimized, chat_emergency
|
| 24 |
from src.api.middleware.rate_limiting import RateLimitMiddleware
|
| 25 |
from src.api.middleware.authentication import AuthenticationMiddleware
|
| 26 |
from src.api.middleware.logging_middleware import LoggingMiddleware
|
|
@@ -349,6 +349,11 @@ app.include_router(
|
|
| 349 |
tags=["Observability"]
|
| 350 |
)
|
| 351 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
|
| 353 |
# Global exception handler
|
| 354 |
@app.exception_handler(CidadaoAIError)
|
|
|
|
| 20 |
from src.core import get_logger, settings
|
| 21 |
from src.core.exceptions import CidadaoAIError, create_error_response
|
| 22 |
from src.core.audit import audit_logger, AuditEventType, AuditSeverity, AuditContext
|
| 23 |
+
from src.api.routes import investigations, analysis, reports, health, auth, oauth, audit, chat, websocket_chat, batch, graphql, cqrs, resilience, observability, chat_simple, chat_stable, chat_optimized, chat_emergency, notifications
|
| 24 |
from src.api.middleware.rate_limiting import RateLimitMiddleware
|
| 25 |
from src.api.middleware.authentication import AuthenticationMiddleware
|
| 26 |
from src.api.middleware.logging_middleware import LoggingMiddleware
|
|
|
|
| 349 |
tags=["Observability"]
|
| 350 |
)
|
| 351 |
|
| 352 |
+
app.include_router(
|
| 353 |
+
notifications.router,
|
| 354 |
+
tags=["Notifications"]
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
|
| 358 |
# Global exception handler
|
| 359 |
@app.exception_handler(CidadaoAIError)
|
src/api/routes/__init__.py
CHANGED
|
@@ -6,6 +6,6 @@ Date: 2025-01-24
|
|
| 6 |
License: Proprietary - All rights reserved
|
| 7 |
"""
|
| 8 |
|
| 9 |
-
from . import health, investigations, analysis, reports, chat, websocket_chat, cqrs, resilience, observability, monitoring, chaos
|
| 10 |
|
| 11 |
-
__all__ = ["health", "investigations", "analysis", "reports", "chat", "websocket_chat", "cqrs", "resilience", "observability", "monitoring", "chaos"]
|
|
|
|
| 6 |
License: Proprietary - All rights reserved
|
| 7 |
"""
|
| 8 |
|
| 9 |
+
from . import health, investigations, analysis, reports, chat, websocket_chat, cqrs, resilience, observability, monitoring, chaos, notifications
|
| 10 |
|
| 11 |
+
__all__ = ["health", "investigations", "analysis", "reports", "chat", "websocket_chat", "cqrs", "resilience", "observability", "monitoring", "chaos", "notifications"]
|
src/api/routes/notifications.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Notification API endpoints."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Optional, Dict, Any
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 5 |
+
from pydantic import BaseModel, EmailStr, HttpUrl
|
| 6 |
+
|
| 7 |
+
from src.services.notification_service import (
|
| 8 |
+
notification_service,
|
| 9 |
+
NotificationType,
|
| 10 |
+
NotificationLevel,
|
| 11 |
+
Notification
|
| 12 |
+
)
|
| 13 |
+
from src.services.webhook_service import webhook_service, WebhookConfig
|
| 14 |
+
from src.models.notification_models import NotificationPreference
|
| 15 |
+
from src.api.dependencies import get_current_user
|
| 16 |
+
from src.core.logging import get_logger
|
| 17 |
+
|
| 18 |
+
logger = get_logger(__name__)
|
| 19 |
+
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class NotificationResponse(BaseModel):
|
| 23 |
+
"""Notification response model."""
|
| 24 |
+
id: str
|
| 25 |
+
type: str
|
| 26 |
+
level: str
|
| 27 |
+
title: str
|
| 28 |
+
message: str
|
| 29 |
+
timestamp: str
|
| 30 |
+
read: bool
|
| 31 |
+
channels_sent: List[str]
|
| 32 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class NotificationPreferencesUpdate(BaseModel):
|
| 36 |
+
"""Update notification preferences."""
|
| 37 |
+
enabled: Optional[bool] = None
|
| 38 |
+
email_enabled: Optional[bool] = None
|
| 39 |
+
webhook_enabled: Optional[bool] = None
|
| 40 |
+
push_enabled: Optional[bool] = None
|
| 41 |
+
frequency: Optional[str] = None
|
| 42 |
+
quiet_hours_start: Optional[str] = None
|
| 43 |
+
quiet_hours_end: Optional[str] = None
|
| 44 |
+
timezone: Optional[str] = None
|
| 45 |
+
type_preferences: Optional[Dict[str, Dict[str, Any]]] = None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class WebhookConfigRequest(BaseModel):
|
| 49 |
+
"""Webhook configuration request."""
|
| 50 |
+
url: HttpUrl
|
| 51 |
+
events: Optional[List[str]] = None
|
| 52 |
+
secret: Optional[str] = None
|
| 53 |
+
description: Optional[str] = None
|
| 54 |
+
headers: Optional[Dict[str, str]] = None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class TestNotificationRequest(BaseModel):
|
| 58 |
+
"""Test notification request."""
|
| 59 |
+
type: NotificationType = NotificationType.SYSTEM_ALERT
|
| 60 |
+
level: NotificationLevel = NotificationLevel.INFO
|
| 61 |
+
title: str = "Test Notification"
|
| 62 |
+
message: str = "This is a test notification from Cidadão.AI"
|
| 63 |
+
channels: Optional[List[str]] = None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@router.get("", response_model=List[NotificationResponse])
|
| 67 |
+
async def get_notifications(
|
| 68 |
+
unread_only: bool = Query(False, description="Filter unread notifications only"),
|
| 69 |
+
type: Optional[str] = Query(None, description="Filter by notification type"),
|
| 70 |
+
level: Optional[str] = Query(None, description="Filter by notification level"),
|
| 71 |
+
limit: int = Query(100, ge=1, le=500, description="Maximum notifications to return"),
|
| 72 |
+
current_user: dict = Depends(get_current_user)
|
| 73 |
+
) -> List[NotificationResponse]:
|
| 74 |
+
"""Get user notifications with filtering options."""
|
| 75 |
+
try:
|
| 76 |
+
# Parse filters
|
| 77 |
+
notification_type = NotificationType(type) if type else None
|
| 78 |
+
notification_level = NotificationLevel(level) if level else None
|
| 79 |
+
|
| 80 |
+
# Get notifications
|
| 81 |
+
notifications = notification_service.get_notifications(
|
| 82 |
+
user_id=current_user["id"],
|
| 83 |
+
unread_only=unread_only,
|
| 84 |
+
type=notification_type,
|
| 85 |
+
level=notification_level,
|
| 86 |
+
limit=limit
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Convert to response format
|
| 90 |
+
return [
|
| 91 |
+
NotificationResponse(
|
| 92 |
+
id=n.id,
|
| 93 |
+
type=n.type.value,
|
| 94 |
+
level=n.level.value,
|
| 95 |
+
title=n.title,
|
| 96 |
+
message=n.message,
|
| 97 |
+
timestamp=n.timestamp.isoformat(),
|
| 98 |
+
read=n.read,
|
| 99 |
+
channels_sent=n.channels_sent,
|
| 100 |
+
metadata=n.metadata
|
| 101 |
+
)
|
| 102 |
+
for n in notifications
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
except ValueError as e:
|
| 106 |
+
raise HTTPException(
|
| 107 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 108 |
+
detail=f"Invalid filter parameter: {str(e)}"
|
| 109 |
+
)
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"Error getting notifications: {e}")
|
| 112 |
+
raise HTTPException(
|
| 113 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 114 |
+
detail="Failed to retrieve notifications"
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@router.get("/unread-count")
|
| 119 |
+
async def get_unread_count(
|
| 120 |
+
current_user: dict = Depends(get_current_user)
|
| 121 |
+
) -> Dict[str, int]:
|
| 122 |
+
"""Get count of unread notifications."""
|
| 123 |
+
notifications = notification_service.get_notifications(
|
| 124 |
+
user_id=current_user["id"],
|
| 125 |
+
unread_only=True
|
| 126 |
+
)
|
| 127 |
+
return {"unread_count": len(notifications)}
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@router.post("/{notification_id}/read")
|
| 131 |
+
async def mark_as_read(
|
| 132 |
+
notification_id: str,
|
| 133 |
+
current_user: dict = Depends(get_current_user)
|
| 134 |
+
) -> Dict[str, bool]:
|
| 135 |
+
"""Mark a notification as read."""
|
| 136 |
+
success = notification_service.mark_as_read(notification_id)
|
| 137 |
+
|
| 138 |
+
if not success:
|
| 139 |
+
raise HTTPException(
|
| 140 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 141 |
+
detail="Notification not found"
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
return {"success": True}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
@router.post("/mark-all-read")
|
| 148 |
+
async def mark_all_as_read(
|
| 149 |
+
current_user: dict = Depends(get_current_user)
|
| 150 |
+
) -> Dict[str, Any]:
|
| 151 |
+
"""Mark all notifications as read."""
|
| 152 |
+
count = notification_service.mark_all_as_read(current_user["id"])
|
| 153 |
+
return {"success": True, "marked_count": count}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@router.delete("/{notification_id}")
|
| 157 |
+
async def delete_notification(
|
| 158 |
+
notification_id: str,
|
| 159 |
+
current_user: dict = Depends(get_current_user)
|
| 160 |
+
) -> Dict[str, bool]:
|
| 161 |
+
"""Delete a notification."""
|
| 162 |
+
success = notification_service.delete_notification(notification_id)
|
| 163 |
+
|
| 164 |
+
if not success:
|
| 165 |
+
raise HTTPException(
|
| 166 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 167 |
+
detail="Notification not found"
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
return {"success": True}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# Preferences endpoints
|
| 174 |
+
@router.get("/preferences", response_model=Dict[str, Any])
|
| 175 |
+
async def get_preferences(
|
| 176 |
+
current_user: dict = Depends(get_current_user)
|
| 177 |
+
) -> Dict[str, Any]:
|
| 178 |
+
"""Get user notification preferences."""
|
| 179 |
+
preferences = notification_service.get_user_preferences(current_user["id"])
|
| 180 |
+
|
| 181 |
+
# Return default preferences if none exist
|
| 182 |
+
if not preferences:
|
| 183 |
+
preferences = {
|
| 184 |
+
"enabled": True,
|
| 185 |
+
"email_enabled": True,
|
| 186 |
+
"webhook_enabled": False,
|
| 187 |
+
"push_enabled": False,
|
| 188 |
+
"frequency": "immediate",
|
| 189 |
+
"type_preferences": {}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
return preferences
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@router.put("/preferences")
|
| 196 |
+
async def update_preferences(
|
| 197 |
+
preferences: NotificationPreferencesUpdate,
|
| 198 |
+
current_user: dict = Depends(get_current_user)
|
| 199 |
+
) -> Dict[str, Any]:
|
| 200 |
+
"""Update user notification preferences."""
|
| 201 |
+
try:
|
| 202 |
+
# Get current preferences
|
| 203 |
+
current_prefs = notification_service.get_user_preferences(current_user["id"]) or {}
|
| 204 |
+
|
| 205 |
+
# Update with new values
|
| 206 |
+
update_dict = preferences.dict(exclude_unset=True)
|
| 207 |
+
current_prefs.update(update_dict)
|
| 208 |
+
|
| 209 |
+
# Save preferences
|
| 210 |
+
await notification_service.set_user_preferences(
|
| 211 |
+
current_user["id"],
|
| 212 |
+
current_prefs
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
return {"success": True, "preferences": current_prefs}
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error(f"Error updating preferences: {e}")
|
| 219 |
+
raise HTTPException(
|
| 220 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 221 |
+
detail="Failed to update preferences"
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# Webhook endpoints
|
| 226 |
+
@router.get("/webhooks", response_model=List[Dict[str, Any]])
|
| 227 |
+
async def get_webhooks(
|
| 228 |
+
current_user: dict = Depends(get_current_user)
|
| 229 |
+
) -> List[Dict[str, Any]]:
|
| 230 |
+
"""Get user's webhook configurations."""
|
| 231 |
+
webhooks = webhook_service.list_webhooks()
|
| 232 |
+
|
| 233 |
+
# Filter by user (in production, this would be from database)
|
| 234 |
+
return [
|
| 235 |
+
{
|
| 236 |
+
"url": str(w.url),
|
| 237 |
+
"events": w.events,
|
| 238 |
+
"active": w.active,
|
| 239 |
+
"max_retries": w.max_retries,
|
| 240 |
+
"timeout": w.timeout
|
| 241 |
+
}
|
| 242 |
+
for w in webhooks
|
| 243 |
+
]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@router.post("/webhooks")
|
| 247 |
+
async def add_webhook(
|
| 248 |
+
webhook: WebhookConfigRequest,
|
| 249 |
+
current_user: dict = Depends(get_current_user)
|
| 250 |
+
) -> Dict[str, Any]:
|
| 251 |
+
"""Add a new webhook configuration."""
|
| 252 |
+
try:
|
| 253 |
+
config = WebhookConfig(
|
| 254 |
+
url=str(webhook.url),
|
| 255 |
+
events=webhook.events,
|
| 256 |
+
secret=webhook.secret,
|
| 257 |
+
headers=webhook.headers
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
webhook_service.add_webhook(config)
|
| 261 |
+
|
| 262 |
+
return {
|
| 263 |
+
"success": True,
|
| 264 |
+
"webhook": {
|
| 265 |
+
"url": str(config.url),
|
| 266 |
+
"events": config.events,
|
| 267 |
+
"active": config.active
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.error(f"Error adding webhook: {e}")
|
| 273 |
+
raise HTTPException(
|
| 274 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 275 |
+
detail="Failed to add webhook"
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@router.delete("/webhooks")
|
| 280 |
+
async def remove_webhook(
|
| 281 |
+
url: str = Query(..., description="Webhook URL to remove"),
|
| 282 |
+
current_user: dict = Depends(get_current_user)
|
| 283 |
+
) -> Dict[str, bool]:
|
| 284 |
+
"""Remove a webhook configuration."""
|
| 285 |
+
success = webhook_service.remove_webhook(url)
|
| 286 |
+
|
| 287 |
+
if not success:
|
| 288 |
+
raise HTTPException(
|
| 289 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 290 |
+
detail="Webhook not found"
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
return {"success": True}
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@router.post("/webhooks/test")
|
| 297 |
+
async def test_webhook(
|
| 298 |
+
webhook: WebhookConfigRequest,
|
| 299 |
+
current_user: dict = Depends(get_current_user)
|
| 300 |
+
) -> Dict[str, Any]:
|
| 301 |
+
"""Test a webhook configuration."""
|
| 302 |
+
try:
|
| 303 |
+
config = WebhookConfig(
|
| 304 |
+
url=str(webhook.url),
|
| 305 |
+
secret=webhook.secret,
|
| 306 |
+
headers=webhook.headers
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
delivery = await webhook_service.test_webhook(config)
|
| 310 |
+
|
| 311 |
+
return {
|
| 312 |
+
"success": delivery.success,
|
| 313 |
+
"status_code": delivery.status_code,
|
| 314 |
+
"duration_ms": delivery.duration_ms,
|
| 315 |
+
"error": delivery.error
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
except Exception as e:
|
| 319 |
+
logger.error(f"Error testing webhook: {e}")
|
| 320 |
+
raise HTTPException(
|
| 321 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 322 |
+
detail=f"Failed to test webhook: {str(e)}"
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# Test endpoints
|
| 327 |
+
@router.post("/test")
|
| 328 |
+
async def send_test_notification(
|
| 329 |
+
request: TestNotificationRequest,
|
| 330 |
+
current_user: dict = Depends(get_current_user)
|
| 331 |
+
) -> Dict[str, Any]:
|
| 332 |
+
"""Send a test notification to the current user."""
|
| 333 |
+
try:
|
| 334 |
+
notification = await notification_service.send_notification(
|
| 335 |
+
user_id=current_user["id"],
|
| 336 |
+
type=request.type,
|
| 337 |
+
title=request.title,
|
| 338 |
+
message=request.message,
|
| 339 |
+
level=request.level,
|
| 340 |
+
channels=request.channels,
|
| 341 |
+
metadata={"test": True}
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
return {
|
| 345 |
+
"success": True,
|
| 346 |
+
"notification_id": notification.id,
|
| 347 |
+
"channels_sent": notification.channels_sent
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
except Exception as e:
|
| 351 |
+
logger.error(f"Error sending test notification: {e}")
|
| 352 |
+
raise HTTPException(
|
| 353 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 354 |
+
detail=f"Failed to send test notification: {str(e)}"
|
| 355 |
+
)
|
src/models/notification_models.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Notification models for database persistence."""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
from typing import List, Optional, Dict, Any
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from pydantic import BaseModel, Field, EmailStr
|
| 7 |
+
from sqlalchemy import (
|
| 8 |
+
Column, String, Boolean, DateTime, JSON, Text,
|
| 9 |
+
Integer, ForeignKey, Table, Enum as SQLEnum
|
| 10 |
+
)
|
| 11 |
+
from sqlalchemy.ext.declarative import declarative_base
|
| 12 |
+
from sqlalchemy.orm import relationship
|
| 13 |
+
|
| 14 |
+
Base = declarative_base()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class NotificationChannel(str, Enum):
|
| 18 |
+
"""Available notification channels."""
|
| 19 |
+
EMAIL = "email"
|
| 20 |
+
WEBHOOK = "webhook"
|
| 21 |
+
PUSH = "push"
|
| 22 |
+
SMS = "sms"
|
| 23 |
+
SLACK = "slack"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class NotificationFrequency(str, Enum):
|
| 27 |
+
"""Notification frequency preferences."""
|
| 28 |
+
IMMEDIATE = "immediate"
|
| 29 |
+
HOURLY = "hourly"
|
| 30 |
+
DAILY = "daily"
|
| 31 |
+
WEEKLY = "weekly"
|
| 32 |
+
MONTHLY = "monthly"
|
| 33 |
+
NEVER = "never"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# Association table for user notification preferences
|
| 37 |
+
user_notification_channels = Table(
|
| 38 |
+
'user_notification_channels',
|
| 39 |
+
Base.metadata,
|
| 40 |
+
Column('user_id', String, ForeignKey('users.id')),
|
| 41 |
+
Column('channel', SQLEnum(NotificationChannel)),
|
| 42 |
+
Column('notification_type', String),
|
| 43 |
+
Column('enabled', Boolean, default=True)
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class NotificationPreferenceDB(Base):
|
| 48 |
+
"""Notification preferences database model."""
|
| 49 |
+
__tablename__ = 'notification_preferences'
|
| 50 |
+
|
| 51 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 52 |
+
user_id = Column(String, ForeignKey('users.id'), nullable=False)
|
| 53 |
+
|
| 54 |
+
# Global preferences
|
| 55 |
+
enabled = Column(Boolean, default=True)
|
| 56 |
+
frequency = Column(SQLEnum(NotificationFrequency), default=NotificationFrequency.IMMEDIATE)
|
| 57 |
+
|
| 58 |
+
# Channel-specific settings
|
| 59 |
+
email_enabled = Column(Boolean, default=True)
|
| 60 |
+
webhook_enabled = Column(Boolean, default=False)
|
| 61 |
+
push_enabled = Column(Boolean, default=False)
|
| 62 |
+
sms_enabled = Column(Boolean, default=False)
|
| 63 |
+
|
| 64 |
+
# Type-specific preferences (JSON)
|
| 65 |
+
type_preferences = Column(JSON, default={})
|
| 66 |
+
|
| 67 |
+
# Contact information
|
| 68 |
+
email_addresses = Column(JSON, default=[]) # List of emails
|
| 69 |
+
webhook_urls = Column(JSON, default=[]) # List of webhook configs
|
| 70 |
+
phone_numbers = Column(JSON, default=[]) # List of phone numbers
|
| 71 |
+
push_tokens = Column(JSON, default=[]) # List of push tokens
|
| 72 |
+
|
| 73 |
+
# Time preferences
|
| 74 |
+
quiet_hours_start = Column(String) # HH:MM format
|
| 75 |
+
quiet_hours_end = Column(String) # HH:MM format
|
| 76 |
+
timezone = Column(String, default='America/Sao_Paulo')
|
| 77 |
+
|
| 78 |
+
# Metadata
|
| 79 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 80 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class NotificationPreference(BaseModel):
|
| 84 |
+
"""Notification preferences Pydantic model."""
|
| 85 |
+
user_id: str
|
| 86 |
+
enabled: bool = True
|
| 87 |
+
frequency: NotificationFrequency = NotificationFrequency.IMMEDIATE
|
| 88 |
+
|
| 89 |
+
# Channel settings
|
| 90 |
+
email_enabled: bool = True
|
| 91 |
+
webhook_enabled: bool = False
|
| 92 |
+
push_enabled: bool = False
|
| 93 |
+
sms_enabled: bool = False
|
| 94 |
+
|
| 95 |
+
# Type-specific preferences
|
| 96 |
+
type_preferences: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
| 97 |
+
|
| 98 |
+
# Contact information
|
| 99 |
+
email_addresses: List[EmailStr] = Field(default_factory=list)
|
| 100 |
+
webhook_urls: List[Dict[str, Any]] = Field(default_factory=list)
|
| 101 |
+
phone_numbers: List[str] = Field(default_factory=list)
|
| 102 |
+
push_tokens: List[str] = Field(default_factory=list)
|
| 103 |
+
|
| 104 |
+
# Time preferences
|
| 105 |
+
quiet_hours_start: Optional[str] = None
|
| 106 |
+
quiet_hours_end: Optional[str] = None
|
| 107 |
+
timezone: str = "America/Sao_Paulo"
|
| 108 |
+
|
| 109 |
+
class Config:
|
| 110 |
+
json_schema_extra = {
|
| 111 |
+
"example": {
|
| 112 |
+
"user_id": "user123",
|
| 113 |
+
"enabled": True,
|
| 114 |
+
"frequency": "immediate",
|
| 115 |
+
"email_enabled": True,
|
| 116 |
+
"webhook_enabled": True,
|
| 117 |
+
"type_preferences": {
|
| 118 |
+
"anomaly_detected": {
|
| 119 |
+
"enabled": True,
|
| 120 |
+
"channels": ["email", "webhook"],
|
| 121 |
+
"min_severity": "medium"
|
| 122 |
+
},
|
| 123 |
+
"investigation_complete": {
|
| 124 |
+
"enabled": True,
|
| 125 |
+
"channels": ["email"],
|
| 126 |
+
"frequency": "daily"
|
| 127 |
+
}
|
| 128 |
+
},
|
| 129 |
+
"email_addresses": ["[email protected]"],
|
| 130 |
+
"webhook_urls": [
|
| 131 |
+
{
|
| 132 |
+
"url": "https://example.com/webhook",
|
| 133 |
+
"secret": "webhook_secret",
|
| 134 |
+
"events": ["anomaly_detected"]
|
| 135 |
+
}
|
| 136 |
+
]
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class NotificationHistoryDB(Base):
|
| 142 |
+
"""Notification history database model."""
|
| 143 |
+
__tablename__ = 'notification_history'
|
| 144 |
+
|
| 145 |
+
id = Column(String, primary_key=True)
|
| 146 |
+
user_id = Column(String, ForeignKey('users.id'), nullable=False)
|
| 147 |
+
|
| 148 |
+
# Notification details
|
| 149 |
+
type = Column(String, nullable=False)
|
| 150 |
+
level = Column(String, nullable=False)
|
| 151 |
+
title = Column(String, nullable=False)
|
| 152 |
+
message = Column(Text, nullable=False)
|
| 153 |
+
|
| 154 |
+
# Delivery status
|
| 155 |
+
channels_requested = Column(JSON, default=[])
|
| 156 |
+
channels_delivered = Column(JSON, default=[])
|
| 157 |
+
delivery_status = Column(JSON, default={}) # Channel -> status mapping
|
| 158 |
+
|
| 159 |
+
# Metadata
|
| 160 |
+
metadata = Column(JSON, default={})
|
| 161 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 162 |
+
read_at = Column(DateTime, nullable=True)
|
| 163 |
+
|
| 164 |
+
# Error tracking
|
| 165 |
+
error_count = Column(Integer, default=0)
|
| 166 |
+
last_error = Column(Text, nullable=True)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class NotificationHistory(BaseModel):
|
| 170 |
+
"""Notification history Pydantic model."""
|
| 171 |
+
id: str
|
| 172 |
+
user_id: str
|
| 173 |
+
type: str
|
| 174 |
+
level: str
|
| 175 |
+
title: str
|
| 176 |
+
message: str
|
| 177 |
+
channels_requested: List[str] = Field(default_factory=list)
|
| 178 |
+
channels_delivered: List[str] = Field(default_factory=list)
|
| 179 |
+
delivery_status: Dict[str, str] = Field(default_factory=dict)
|
| 180 |
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
| 181 |
+
created_at: datetime
|
| 182 |
+
read_at: Optional[datetime] = None
|
| 183 |
+
error_count: int = 0
|
| 184 |
+
last_error: Optional[str] = None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class WebhookConfigDB(Base):
|
| 188 |
+
"""Webhook configuration database model."""
|
| 189 |
+
__tablename__ = 'webhook_configs'
|
| 190 |
+
|
| 191 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 192 |
+
user_id = Column(String, ForeignKey('users.id'), nullable=False)
|
| 193 |
+
|
| 194 |
+
# Webhook details
|
| 195 |
+
url = Column(String, nullable=False)
|
| 196 |
+
secret = Column(String, nullable=True)
|
| 197 |
+
description = Column(String, nullable=True)
|
| 198 |
+
|
| 199 |
+
# Event filtering
|
| 200 |
+
events = Column(JSON, default=[]) # List of event types
|
| 201 |
+
active = Column(Boolean, default=True)
|
| 202 |
+
|
| 203 |
+
# Headers and authentication
|
| 204 |
+
headers = Column(JSON, default={})
|
| 205 |
+
auth_type = Column(String, nullable=True) # 'basic', 'bearer', 'custom'
|
| 206 |
+
auth_value = Column(String, nullable=True)
|
| 207 |
+
|
| 208 |
+
# Retry configuration
|
| 209 |
+
max_retries = Column(Integer, default=3)
|
| 210 |
+
timeout_seconds = Column(Integer, default=30)
|
| 211 |
+
|
| 212 |
+
# Metadata
|
| 213 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 214 |
+
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
| 215 |
+
last_triggered_at = Column(DateTime, nullable=True)
|
| 216 |
+
success_count = Column(Integer, default=0)
|
| 217 |
+
failure_count = Column(Integer, default=0)
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class WebhookConfig(BaseModel):
|
| 221 |
+
"""Webhook configuration Pydantic model."""
|
| 222 |
+
id: Optional[int] = None
|
| 223 |
+
user_id: str
|
| 224 |
+
url: str
|
| 225 |
+
secret: Optional[str] = None
|
| 226 |
+
description: Optional[str] = None
|
| 227 |
+
events: List[str] = Field(default_factory=list)
|
| 228 |
+
active: bool = True
|
| 229 |
+
headers: Dict[str, str] = Field(default_factory=dict)
|
| 230 |
+
auth_type: Optional[str] = None
|
| 231 |
+
auth_value: Optional[str] = None
|
| 232 |
+
max_retries: int = 3
|
| 233 |
+
timeout_seconds: int = 30
|
src/services/email_service.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Email service for sending notifications via SMTP.
|
| 2 |
+
|
| 3 |
+
This service provides async email sending capabilities with support for:
|
| 4 |
+
- HTML and plain text emails
|
| 5 |
+
- Attachments
|
| 6 |
+
- Multiple recipients (to, cc, bcc)
|
| 7 |
+
- Email templates using Jinja2
|
| 8 |
+
- Retry logic with exponential backoff
|
| 9 |
+
- Connection pooling
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
from email.mime.multipart import MIMEMultipart
|
| 14 |
+
from email.mime.text import MIMEText
|
| 15 |
+
from email.mime.base import MIMEBase
|
| 16 |
+
from email import encoders
|
| 17 |
+
import os
|
| 18 |
+
from typing import List, Optional, Dict, Any, Union
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
import aiosmtplib
|
| 21 |
+
from email_validator import validate_email, EmailNotValidError
|
| 22 |
+
from pydantic import BaseModel, EmailStr, Field, validator
|
| 23 |
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
| 24 |
+
import structlog
|
| 25 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 26 |
+
|
| 27 |
+
from src.core.config import settings
|
| 28 |
+
from src.core.logging import get_logger
|
| 29 |
+
|
| 30 |
+
logger = get_logger(__name__)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class EmailAttachment(BaseModel):
|
| 34 |
+
"""Email attachment model."""
|
| 35 |
+
filename: str
|
| 36 |
+
content: Union[bytes, str]
|
| 37 |
+
content_type: str = "application/octet-stream"
|
| 38 |
+
|
| 39 |
+
@validator("content")
|
| 40 |
+
def validate_content(cls, v):
|
| 41 |
+
"""Ensure content is bytes."""
|
| 42 |
+
if isinstance(v, str):
|
| 43 |
+
return v.encode()
|
| 44 |
+
return v
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class EmailMessage(BaseModel):
|
| 48 |
+
"""Email message model with validation."""
|
| 49 |
+
to: List[EmailStr]
|
| 50 |
+
subject: str
|
| 51 |
+
body: Optional[str] = None
|
| 52 |
+
html_body: Optional[str] = None
|
| 53 |
+
cc: Optional[List[EmailStr]] = None
|
| 54 |
+
bcc: Optional[List[EmailStr]] = None
|
| 55 |
+
reply_to: Optional[EmailStr] = None
|
| 56 |
+
attachments: Optional[List[EmailAttachment]] = None
|
| 57 |
+
headers: Optional[Dict[str, str]] = None
|
| 58 |
+
template: Optional[str] = None
|
| 59 |
+
template_data: Optional[Dict[str, Any]] = None
|
| 60 |
+
|
| 61 |
+
@validator("to", "cc", "bcc", pre=True)
|
| 62 |
+
def validate_emails(cls, v):
|
| 63 |
+
"""Validate email addresses."""
|
| 64 |
+
if v is None:
|
| 65 |
+
return v
|
| 66 |
+
if isinstance(v, str):
|
| 67 |
+
v = [v]
|
| 68 |
+
validated = []
|
| 69 |
+
for email in v:
|
| 70 |
+
try:
|
| 71 |
+
validated_email = validate_email(email)
|
| 72 |
+
validated.append(validated_email.email)
|
| 73 |
+
except EmailNotValidError as e:
|
| 74 |
+
logger.warning(f"Invalid email address: {email} - {e}")
|
| 75 |
+
continue
|
| 76 |
+
return validated
|
| 77 |
+
|
| 78 |
+
@validator("body", always=True)
|
| 79 |
+
def validate_body(cls, v, values):
|
| 80 |
+
"""Ensure at least one body type is provided."""
|
| 81 |
+
if not v and not values.get("html_body") and not values.get("template"):
|
| 82 |
+
raise ValueError("Either body, html_body, or template must be provided")
|
| 83 |
+
return v
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class SMTPConfig(BaseModel):
|
| 87 |
+
"""SMTP configuration model."""
|
| 88 |
+
host: str = Field(default_factory=lambda: settings.smtp_host)
|
| 89 |
+
port: int = Field(default_factory=lambda: settings.smtp_port)
|
| 90 |
+
username: Optional[str] = Field(default_factory=lambda: settings.smtp_username)
|
| 91 |
+
password: Optional[str] = Field(default_factory=lambda: settings.smtp_password.get_secret_value() if settings.smtp_password else None)
|
| 92 |
+
use_tls: bool = Field(default_factory=lambda: settings.smtp_use_tls)
|
| 93 |
+
use_ssl: bool = Field(default_factory=lambda: settings.smtp_use_ssl)
|
| 94 |
+
timeout: int = Field(default=30)
|
| 95 |
+
from_email: EmailStr = Field(default_factory=lambda: settings.smtp_from_email)
|
| 96 |
+
from_name: str = Field(default_factory=lambda: settings.smtp_from_name)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class EmailService:
|
| 100 |
+
"""Service for sending emails via SMTP."""
|
| 101 |
+
|
| 102 |
+
def __init__(self, config: Optional[SMTPConfig] = None):
|
| 103 |
+
"""Initialize email service.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
config: SMTP configuration. If not provided, uses settings.
|
| 107 |
+
"""
|
| 108 |
+
self.config = config or SMTPConfig()
|
| 109 |
+
self._template_env = self._setup_template_environment()
|
| 110 |
+
self._connection_lock = asyncio.Lock()
|
| 111 |
+
self._smtp_client: Optional[aiosmtplib.SMTP] = None
|
| 112 |
+
|
| 113 |
+
def _setup_template_environment(self) -> Environment:
|
| 114 |
+
"""Setup Jinja2 template environment."""
|
| 115 |
+
template_dir = Path(__file__).parent.parent / "templates" / "email"
|
| 116 |
+
template_dir.mkdir(parents=True, exist_ok=True)
|
| 117 |
+
|
| 118 |
+
return Environment(
|
| 119 |
+
loader=FileSystemLoader(str(template_dir)),
|
| 120 |
+
autoescape=select_autoescape(["html", "xml"]),
|
| 121 |
+
enable_async=True
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
async def _get_smtp_client(self) -> aiosmtplib.SMTP:
|
| 125 |
+
"""Get or create SMTP client with connection pooling."""
|
| 126 |
+
async with self._connection_lock:
|
| 127 |
+
if self._smtp_client is None or not self._smtp_client.is_connected:
|
| 128 |
+
self._smtp_client = aiosmtplib.SMTP(
|
| 129 |
+
hostname=self.config.host,
|
| 130 |
+
port=self.config.port,
|
| 131 |
+
timeout=self.config.timeout,
|
| 132 |
+
use_tls=self.config.use_ssl
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
await self._smtp_client.connect()
|
| 136 |
+
|
| 137 |
+
if self.config.use_tls and not self.config.use_ssl:
|
| 138 |
+
await self._smtp_client.starttls()
|
| 139 |
+
|
| 140 |
+
if self.config.username and self.config.password:
|
| 141 |
+
await self._smtp_client.login(
|
| 142 |
+
self.config.username,
|
| 143 |
+
self.config.password
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
return self._smtp_client
|
| 147 |
+
|
| 148 |
+
async def _render_template(
|
| 149 |
+
self,
|
| 150 |
+
template_name: str,
|
| 151 |
+
context: Dict[str, Any]
|
| 152 |
+
) -> tuple[str, str]:
|
| 153 |
+
"""Render email template.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
template_name: Name of the template file
|
| 157 |
+
context: Template context data
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
Tuple of (html_body, text_body)
|
| 161 |
+
"""
|
| 162 |
+
# Add default context
|
| 163 |
+
default_context = {
|
| 164 |
+
"app_name": "Cidadão.AI",
|
| 165 |
+
"app_url": settings.app_url,
|
| 166 |
+
"support_email": settings.support_email,
|
| 167 |
+
}
|
| 168 |
+
context = {**default_context, **context}
|
| 169 |
+
|
| 170 |
+
# Render HTML template
|
| 171 |
+
html_template = self._template_env.get_template(f"{template_name}.html")
|
| 172 |
+
html_body = await html_template.render_async(**context)
|
| 173 |
+
|
| 174 |
+
# Try to render text template, fallback to HTML strip
|
| 175 |
+
try:
|
| 176 |
+
text_template = self._template_env.get_template(f"{template_name}.txt")
|
| 177 |
+
text_body = await text_template.render_async(**context)
|
| 178 |
+
except Exception:
|
| 179 |
+
# Simple HTML to text conversion
|
| 180 |
+
import re
|
| 181 |
+
text_body = re.sub(r"<[^>]+>", "", html_body)
|
| 182 |
+
text_body = re.sub(r"\s+", " ", text_body).strip()
|
| 183 |
+
|
| 184 |
+
return html_body, text_body
|
| 185 |
+
|
| 186 |
+
def _create_message(self, email: EmailMessage) -> MIMEMultipart:
|
| 187 |
+
"""Create MIME message from EmailMessage."""
|
| 188 |
+
msg = MIMEMultipart("alternative")
|
| 189 |
+
|
| 190 |
+
# Set headers
|
| 191 |
+
msg["Subject"] = email.subject
|
| 192 |
+
msg["From"] = f"{self.config.from_name} <{self.config.from_email}>"
|
| 193 |
+
msg["To"] = ", ".join(email.to)
|
| 194 |
+
|
| 195 |
+
if email.cc:
|
| 196 |
+
msg["Cc"] = ", ".join(email.cc)
|
| 197 |
+
|
| 198 |
+
if email.reply_to:
|
| 199 |
+
msg["Reply-To"] = email.reply_to
|
| 200 |
+
|
| 201 |
+
# Add custom headers
|
| 202 |
+
if email.headers:
|
| 203 |
+
for key, value in email.headers.items():
|
| 204 |
+
msg[key] = value
|
| 205 |
+
|
| 206 |
+
# Add text part
|
| 207 |
+
if email.body:
|
| 208 |
+
msg.attach(MIMEText(email.body, "plain"))
|
| 209 |
+
|
| 210 |
+
# Add HTML part
|
| 211 |
+
if email.html_body:
|
| 212 |
+
msg.attach(MIMEText(email.html_body, "html"))
|
| 213 |
+
|
| 214 |
+
# Add attachments
|
| 215 |
+
if email.attachments:
|
| 216 |
+
for attachment in email.attachments:
|
| 217 |
+
part = MIMEBase("application", "octet-stream")
|
| 218 |
+
part.set_payload(attachment.content)
|
| 219 |
+
encoders.encode_base64(part)
|
| 220 |
+
part.add_header(
|
| 221 |
+
"Content-Disposition",
|
| 222 |
+
f"attachment; filename={attachment.filename}"
|
| 223 |
+
)
|
| 224 |
+
msg.attach(part)
|
| 225 |
+
|
| 226 |
+
return msg
|
| 227 |
+
|
| 228 |
+
@retry(
|
| 229 |
+
stop=stop_after_attempt(3),
|
| 230 |
+
wait=wait_exponential(multiplier=1, min=4, max=10)
|
| 231 |
+
)
|
| 232 |
+
async def send_email(self, email: EmailMessage) -> bool:
|
| 233 |
+
"""Send an email message.
|
| 234 |
+
|
| 235 |
+
Args:
|
| 236 |
+
email: Email message to send
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
True if email was sent successfully
|
| 240 |
+
"""
|
| 241 |
+
try:
|
| 242 |
+
# Render template if specified
|
| 243 |
+
if email.template:
|
| 244 |
+
html_body, text_body = await self._render_template(
|
| 245 |
+
email.template,
|
| 246 |
+
email.template_data or {}
|
| 247 |
+
)
|
| 248 |
+
email.html_body = html_body
|
| 249 |
+
email.body = text_body
|
| 250 |
+
|
| 251 |
+
# Create MIME message
|
| 252 |
+
msg = self._create_message(email)
|
| 253 |
+
|
| 254 |
+
# Get SMTP client
|
| 255 |
+
smtp = await self._get_smtp_client()
|
| 256 |
+
|
| 257 |
+
# Prepare recipients
|
| 258 |
+
recipients = email.to.copy()
|
| 259 |
+
if email.cc:
|
| 260 |
+
recipients.extend(email.cc)
|
| 261 |
+
if email.bcc:
|
| 262 |
+
recipients.extend(email.bcc)
|
| 263 |
+
|
| 264 |
+
# Send email
|
| 265 |
+
await smtp.send_message(msg)
|
| 266 |
+
|
| 267 |
+
logger.info(
|
| 268 |
+
"Email sent successfully",
|
| 269 |
+
subject=email.subject,
|
| 270 |
+
recipients=len(recipients),
|
| 271 |
+
has_attachments=bool(email.attachments)
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
return True
|
| 275 |
+
|
| 276 |
+
except Exception as e:
|
| 277 |
+
logger.error(
|
| 278 |
+
"Failed to send email",
|
| 279 |
+
subject=email.subject,
|
| 280 |
+
error=str(e),
|
| 281 |
+
exc_info=True
|
| 282 |
+
)
|
| 283 |
+
raise
|
| 284 |
+
|
| 285 |
+
async def send_batch(
|
| 286 |
+
self,
|
| 287 |
+
emails: List[EmailMessage],
|
| 288 |
+
max_concurrent: int = 5
|
| 289 |
+
) -> List[bool]:
|
| 290 |
+
"""Send multiple emails concurrently.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
emails: List of email messages
|
| 294 |
+
max_concurrent: Maximum concurrent sends
|
| 295 |
+
|
| 296 |
+
Returns:
|
| 297 |
+
List of success status for each email
|
| 298 |
+
"""
|
| 299 |
+
semaphore = asyncio.Semaphore(max_concurrent)
|
| 300 |
+
|
| 301 |
+
async def send_with_semaphore(email: EmailMessage) -> bool:
|
| 302 |
+
async with semaphore:
|
| 303 |
+
try:
|
| 304 |
+
return await self.send_email(email)
|
| 305 |
+
except Exception:
|
| 306 |
+
return False
|
| 307 |
+
|
| 308 |
+
tasks = [send_with_semaphore(email) for email in emails]
|
| 309 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 310 |
+
|
| 311 |
+
return [
|
| 312 |
+
result if isinstance(result, bool) else False
|
| 313 |
+
for result in results
|
| 314 |
+
]
|
| 315 |
+
|
| 316 |
+
async def close(self):
|
| 317 |
+
"""Close SMTP connection."""
|
| 318 |
+
if self._smtp_client and self._smtp_client.is_connected:
|
| 319 |
+
await self._smtp_client.quit()
|
| 320 |
+
self._smtp_client = None
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
# Singleton instance
|
| 324 |
+
email_service = EmailService()
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# Convenience functions
|
| 328 |
+
async def send_email(
|
| 329 |
+
to: Union[str, List[str]],
|
| 330 |
+
subject: str,
|
| 331 |
+
body: Optional[str] = None,
|
| 332 |
+
html_body: Optional[str] = None,
|
| 333 |
+
template: Optional[str] = None,
|
| 334 |
+
template_data: Optional[Dict[str, Any]] = None,
|
| 335 |
+
attachments: Optional[List[EmailAttachment]] = None,
|
| 336 |
+
**kwargs
|
| 337 |
+
) -> bool:
|
| 338 |
+
"""Send an email using the default email service.
|
| 339 |
+
|
| 340 |
+
Args:
|
| 341 |
+
to: Recipient email address(es)
|
| 342 |
+
subject: Email subject
|
| 343 |
+
body: Plain text body
|
| 344 |
+
html_body: HTML body
|
| 345 |
+
template: Template name to render
|
| 346 |
+
template_data: Data for template rendering
|
| 347 |
+
attachments: List of attachments
|
| 348 |
+
**kwargs: Additional email fields (cc, bcc, reply_to, headers)
|
| 349 |
+
|
| 350 |
+
Returns:
|
| 351 |
+
True if email was sent successfully
|
| 352 |
+
"""
|
| 353 |
+
if isinstance(to, str):
|
| 354 |
+
to = [to]
|
| 355 |
+
|
| 356 |
+
email = EmailMessage(
|
| 357 |
+
to=to,
|
| 358 |
+
subject=subject,
|
| 359 |
+
body=body,
|
| 360 |
+
html_body=html_body,
|
| 361 |
+
template=template,
|
| 362 |
+
template_data=template_data,
|
| 363 |
+
attachments=attachments,
|
| 364 |
+
**kwargs
|
| 365 |
+
)
|
| 366 |
+
|
| 367 |
+
return await email_service.send_email(email)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
async def send_template_email(
|
| 371 |
+
to: Union[str, List[str]],
|
| 372 |
+
subject: str,
|
| 373 |
+
template: str,
|
| 374 |
+
template_data: Optional[Dict[str, Any]] = None,
|
| 375 |
+
**kwargs
|
| 376 |
+
) -> bool:
|
| 377 |
+
"""Send an email using a template.
|
| 378 |
+
|
| 379 |
+
Args:
|
| 380 |
+
to: Recipient email address(es)
|
| 381 |
+
subject: Email subject
|
| 382 |
+
template: Template name
|
| 383 |
+
template_data: Template context data
|
| 384 |
+
**kwargs: Additional email fields
|
| 385 |
+
|
| 386 |
+
Returns:
|
| 387 |
+
True if email was sent successfully
|
| 388 |
+
"""
|
| 389 |
+
return await send_email(
|
| 390 |
+
to=to,
|
| 391 |
+
subject=subject,
|
| 392 |
+
template=template,
|
| 393 |
+
template_data=template_data,
|
| 394 |
+
**kwargs
|
| 395 |
+
)
|
src/services/webhook_service.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Webhook service for sending notifications to external endpoints.
|
| 2 |
+
|
| 3 |
+
This service provides async webhook delivery with:
|
| 4 |
+
- Retry logic with exponential backoff
|
| 5 |
+
- Request signing for security
|
| 6 |
+
- Batch webhook sending
|
| 7 |
+
- Event filtering
|
| 8 |
+
- Delivery status tracking
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import hashlib
|
| 13 |
+
import hmac
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from typing import List, Dict, Any, Optional, Union
|
| 16 |
+
from enum import Enum
|
| 17 |
+
import httpx
|
| 18 |
+
from pydantic import BaseModel, HttpUrl, Field, validator
|
| 19 |
+
import structlog
|
| 20 |
+
from tenacity import (
|
| 21 |
+
retry,
|
| 22 |
+
stop_after_attempt,
|
| 23 |
+
wait_exponential,
|
| 24 |
+
retry_if_exception_type
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
from src.core.config import settings
|
| 28 |
+
from src.core.logging import get_logger
|
| 29 |
+
from src.core import json_utils
|
| 30 |
+
|
| 31 |
+
logger = get_logger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class WebhookEvent(str, Enum):
|
| 35 |
+
"""Webhook event types."""
|
| 36 |
+
INVESTIGATION_CREATED = "investigation.created"
|
| 37 |
+
INVESTIGATION_COMPLETED = "investigation.completed"
|
| 38 |
+
INVESTIGATION_FAILED = "investigation.failed"
|
| 39 |
+
|
| 40 |
+
ANOMALY_DETECTED = "anomaly.detected"
|
| 41 |
+
ANOMALY_RESOLVED = "anomaly.resolved"
|
| 42 |
+
|
| 43 |
+
AGENT_STARTED = "agent.started"
|
| 44 |
+
AGENT_COMPLETED = "agent.completed"
|
| 45 |
+
AGENT_FAILED = "agent.failed"
|
| 46 |
+
|
| 47 |
+
REPORT_GENERATED = "report.generated"
|
| 48 |
+
EXPORT_COMPLETED = "export.completed"
|
| 49 |
+
|
| 50 |
+
USER_REGISTERED = "user.registered"
|
| 51 |
+
USER_LOGIN = "user.login"
|
| 52 |
+
|
| 53 |
+
SYSTEM_ALERT = "system.alert"
|
| 54 |
+
SYSTEM_ERROR = "system.error"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class WebhookPayload(BaseModel):
|
| 58 |
+
"""Webhook payload model."""
|
| 59 |
+
event: WebhookEvent
|
| 60 |
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
| 61 |
+
data: Dict[str, Any]
|
| 62 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 63 |
+
|
| 64 |
+
@validator("timestamp", pre=True)
|
| 65 |
+
def ensure_timezone(cls, v):
|
| 66 |
+
"""Ensure timestamp has timezone."""
|
| 67 |
+
if isinstance(v, datetime) and v.tzinfo is None:
|
| 68 |
+
return v.replace(tzinfo=timezone.utc)
|
| 69 |
+
return v
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class WebhookConfig(BaseModel):
|
| 73 |
+
"""Webhook configuration model."""
|
| 74 |
+
url: HttpUrl
|
| 75 |
+
secret: Optional[str] = None
|
| 76 |
+
events: Optional[List[WebhookEvent]] = None # None means all events
|
| 77 |
+
headers: Optional[Dict[str, str]] = None
|
| 78 |
+
timeout: int = Field(default=30, ge=1, le=300)
|
| 79 |
+
max_retries: int = Field(default=3, ge=0, le=10)
|
| 80 |
+
active: bool = Field(default=True)
|
| 81 |
+
|
| 82 |
+
def should_send_event(self, event: WebhookEvent) -> bool:
|
| 83 |
+
"""Check if webhook should receive this event."""
|
| 84 |
+
if not self.active:
|
| 85 |
+
return False
|
| 86 |
+
if self.events is None:
|
| 87 |
+
return True
|
| 88 |
+
return event in self.events
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class WebhookDelivery(BaseModel):
|
| 92 |
+
"""Webhook delivery result."""
|
| 93 |
+
webhook_url: str
|
| 94 |
+
event: WebhookEvent
|
| 95 |
+
timestamp: datetime
|
| 96 |
+
status_code: Optional[int] = None
|
| 97 |
+
response_body: Optional[str] = None
|
| 98 |
+
error: Optional[str] = None
|
| 99 |
+
attempts: int = 0
|
| 100 |
+
success: bool = False
|
| 101 |
+
duration_ms: Optional[float] = None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class WebhookService:
|
| 105 |
+
"""Service for managing and sending webhooks."""
|
| 106 |
+
|
| 107 |
+
def __init__(self):
|
| 108 |
+
"""Initialize webhook service."""
|
| 109 |
+
self._webhooks: List[WebhookConfig] = []
|
| 110 |
+
self._client = httpx.AsyncClient(
|
| 111 |
+
timeout=httpx.Timeout(30.0),
|
| 112 |
+
follow_redirects=False,
|
| 113 |
+
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20)
|
| 114 |
+
)
|
| 115 |
+
self._delivery_history: List[WebhookDelivery] = []
|
| 116 |
+
self._max_history = 1000
|
| 117 |
+
|
| 118 |
+
def add_webhook(self, webhook: WebhookConfig) -> None:
|
| 119 |
+
"""Add a webhook configuration."""
|
| 120 |
+
self._webhooks.append(webhook)
|
| 121 |
+
logger.info(
|
| 122 |
+
"Webhook added",
|
| 123 |
+
url=str(webhook.url),
|
| 124 |
+
events=webhook.events,
|
| 125 |
+
active=webhook.active
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
def remove_webhook(self, url: str) -> bool:
|
| 129 |
+
"""Remove a webhook by URL."""
|
| 130 |
+
initial_count = len(self._webhooks)
|
| 131 |
+
self._webhooks = [w for w in self._webhooks if str(w.url) != url]
|
| 132 |
+
removed = len(self._webhooks) < initial_count
|
| 133 |
+
|
| 134 |
+
if removed:
|
| 135 |
+
logger.info("Webhook removed", url=url)
|
| 136 |
+
|
| 137 |
+
return removed
|
| 138 |
+
|
| 139 |
+
def list_webhooks(self) -> List[WebhookConfig]:
|
| 140 |
+
"""List all configured webhooks."""
|
| 141 |
+
return self._webhooks.copy()
|
| 142 |
+
|
| 143 |
+
def _generate_signature(self, payload: bytes, secret: str) -> str:
|
| 144 |
+
"""Generate HMAC signature for webhook payload."""
|
| 145 |
+
signature = hmac.new(
|
| 146 |
+
secret.encode(),
|
| 147 |
+
payload,
|
| 148 |
+
hashlib.sha256
|
| 149 |
+
).hexdigest()
|
| 150 |
+
return f"sha256={signature}"
|
| 151 |
+
|
| 152 |
+
def _prepare_request(
|
| 153 |
+
self,
|
| 154 |
+
webhook: WebhookConfig,
|
| 155 |
+
payload: WebhookPayload
|
| 156 |
+
) -> tuple[Dict[str, str], bytes]:
|
| 157 |
+
"""Prepare webhook request headers and body."""
|
| 158 |
+
# Serialize payload
|
| 159 |
+
body_data = {
|
| 160 |
+
"event": payload.event,
|
| 161 |
+
"timestamp": payload.timestamp.isoformat(),
|
| 162 |
+
"data": payload.data,
|
| 163 |
+
}
|
| 164 |
+
if payload.metadata:
|
| 165 |
+
body_data["metadata"] = payload.metadata
|
| 166 |
+
|
| 167 |
+
body = json_utils.dumps(body_data).encode()
|
| 168 |
+
|
| 169 |
+
# Prepare headers
|
| 170 |
+
headers = {
|
| 171 |
+
"Content-Type": "application/json",
|
| 172 |
+
"User-Agent": "Cidadao.AI/1.0",
|
| 173 |
+
"X-Cidadao-Event": payload.event,
|
| 174 |
+
"X-Cidadao-Timestamp": payload.timestamp.isoformat(),
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
# Add signature if secret is configured
|
| 178 |
+
if webhook.secret:
|
| 179 |
+
headers["X-Cidadao-Signature"] = self._generate_signature(body, webhook.secret)
|
| 180 |
+
|
| 181 |
+
# Add custom headers
|
| 182 |
+
if webhook.headers:
|
| 183 |
+
headers.update(webhook.headers)
|
| 184 |
+
|
| 185 |
+
return headers, body
|
| 186 |
+
|
| 187 |
+
@retry(
|
| 188 |
+
stop=stop_after_attempt(3),
|
| 189 |
+
wait=wait_exponential(multiplier=1, min=4, max=10),
|
| 190 |
+
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError))
|
| 191 |
+
)
|
| 192 |
+
async def _send_webhook(
|
| 193 |
+
self,
|
| 194 |
+
webhook: WebhookConfig,
|
| 195 |
+
payload: WebhookPayload
|
| 196 |
+
) -> WebhookDelivery:
|
| 197 |
+
"""Send a single webhook with retry logic."""
|
| 198 |
+
delivery = WebhookDelivery(
|
| 199 |
+
webhook_url=str(webhook.url),
|
| 200 |
+
event=payload.event,
|
| 201 |
+
timestamp=datetime.now(timezone.utc)
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
# Prepare request
|
| 206 |
+
headers, body = self._prepare_request(webhook, payload)
|
| 207 |
+
|
| 208 |
+
# Send request
|
| 209 |
+
start_time = asyncio.get_event_loop().time()
|
| 210 |
+
response = await self._client.post(
|
| 211 |
+
str(webhook.url),
|
| 212 |
+
headers=headers,
|
| 213 |
+
content=body,
|
| 214 |
+
timeout=webhook.timeout
|
| 215 |
+
)
|
| 216 |
+
end_time = asyncio.get_event_loop().time()
|
| 217 |
+
|
| 218 |
+
# Update delivery info
|
| 219 |
+
delivery.status_code = response.status_code
|
| 220 |
+
delivery.response_body = response.text[:1000] # Limit response size
|
| 221 |
+
delivery.success = 200 <= response.status_code < 300
|
| 222 |
+
delivery.duration_ms = (end_time - start_time) * 1000
|
| 223 |
+
delivery.attempts = 1 # Will be updated by retry decorator
|
| 224 |
+
|
| 225 |
+
if not delivery.success:
|
| 226 |
+
logger.warning(
|
| 227 |
+
"Webhook delivery failed",
|
| 228 |
+
url=str(webhook.url),
|
| 229 |
+
status_code=response.status_code,
|
| 230 |
+
response=delivery.response_body
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
except Exception as e:
|
| 234 |
+
delivery.error = str(e)
|
| 235 |
+
delivery.success = False
|
| 236 |
+
logger.error(
|
| 237 |
+
"Webhook delivery error",
|
| 238 |
+
url=str(webhook.url),
|
| 239 |
+
error=str(e),
|
| 240 |
+
exc_info=True
|
| 241 |
+
)
|
| 242 |
+
raise
|
| 243 |
+
|
| 244 |
+
return delivery
|
| 245 |
+
|
| 246 |
+
async def send_event(
|
| 247 |
+
self,
|
| 248 |
+
event: WebhookEvent,
|
| 249 |
+
data: Dict[str, Any],
|
| 250 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 251 |
+
) -> List[WebhookDelivery]:
|
| 252 |
+
"""Send an event to all configured webhooks.
|
| 253 |
+
|
| 254 |
+
Args:
|
| 255 |
+
event: Event type
|
| 256 |
+
data: Event data
|
| 257 |
+
metadata: Optional metadata
|
| 258 |
+
|
| 259 |
+
Returns:
|
| 260 |
+
List of delivery results
|
| 261 |
+
"""
|
| 262 |
+
payload = WebhookPayload(
|
| 263 |
+
event=event,
|
| 264 |
+
data=data,
|
| 265 |
+
metadata=metadata
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
# Filter webhooks for this event
|
| 269 |
+
webhooks_to_send = [
|
| 270 |
+
webhook for webhook in self._webhooks
|
| 271 |
+
if webhook.should_send_event(event)
|
| 272 |
+
]
|
| 273 |
+
|
| 274 |
+
if not webhooks_to_send:
|
| 275 |
+
logger.debug("No webhooks configured for event", event=event)
|
| 276 |
+
return []
|
| 277 |
+
|
| 278 |
+
# Send webhooks concurrently
|
| 279 |
+
tasks = [
|
| 280 |
+
self._send_webhook(webhook, payload)
|
| 281 |
+
for webhook in webhooks_to_send
|
| 282 |
+
]
|
| 283 |
+
|
| 284 |
+
deliveries = await asyncio.gather(*tasks, return_exceptions=True)
|
| 285 |
+
|
| 286 |
+
# Process results
|
| 287 |
+
results = []
|
| 288 |
+
for delivery in deliveries:
|
| 289 |
+
if isinstance(delivery, Exception):
|
| 290 |
+
# Create failed delivery record
|
| 291 |
+
delivery = WebhookDelivery(
|
| 292 |
+
webhook_url="unknown",
|
| 293 |
+
event=event,
|
| 294 |
+
timestamp=datetime.now(timezone.utc),
|
| 295 |
+
error=str(delivery),
|
| 296 |
+
success=False
|
| 297 |
+
)
|
| 298 |
+
results.append(delivery)
|
| 299 |
+
|
| 300 |
+
# Store in history
|
| 301 |
+
self._store_deliveries(results)
|
| 302 |
+
|
| 303 |
+
# Log summary
|
| 304 |
+
successful = sum(1 for d in results if d.success)
|
| 305 |
+
logger.info(
|
| 306 |
+
"Webhooks sent",
|
| 307 |
+
event=event,
|
| 308 |
+
total=len(results),
|
| 309 |
+
successful=successful,
|
| 310 |
+
failed=len(results) - successful
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
return results
|
| 314 |
+
|
| 315 |
+
def _store_deliveries(self, deliveries: List[WebhookDelivery]) -> None:
|
| 316 |
+
"""Store delivery results in history."""
|
| 317 |
+
self._delivery_history.extend(deliveries)
|
| 318 |
+
|
| 319 |
+
# Trim history if too large
|
| 320 |
+
if len(self._delivery_history) > self._max_history:
|
| 321 |
+
self._delivery_history = self._delivery_history[-self._max_history:]
|
| 322 |
+
|
| 323 |
+
def get_delivery_history(
|
| 324 |
+
self,
|
| 325 |
+
event: Optional[WebhookEvent] = None,
|
| 326 |
+
url: Optional[str] = None,
|
| 327 |
+
success: Optional[bool] = None,
|
| 328 |
+
limit: int = 100
|
| 329 |
+
) -> List[WebhookDelivery]:
|
| 330 |
+
"""Get webhook delivery history with filtering."""
|
| 331 |
+
history = self._delivery_history.copy()
|
| 332 |
+
|
| 333 |
+
# Apply filters
|
| 334 |
+
if event:
|
| 335 |
+
history = [d for d in history if d.event == event]
|
| 336 |
+
if url:
|
| 337 |
+
history = [d for d in history if d.webhook_url == url]
|
| 338 |
+
if success is not None:
|
| 339 |
+
history = [d for d in history if d.success == success]
|
| 340 |
+
|
| 341 |
+
# Sort by timestamp (newest first) and limit
|
| 342 |
+
history.sort(key=lambda d: d.timestamp, reverse=True)
|
| 343 |
+
return history[:limit]
|
| 344 |
+
|
| 345 |
+
async def test_webhook(self, webhook: WebhookConfig) -> WebhookDelivery:
|
| 346 |
+
"""Test a webhook configuration."""
|
| 347 |
+
test_payload = WebhookPayload(
|
| 348 |
+
event=WebhookEvent.SYSTEM_ALERT,
|
| 349 |
+
data={
|
| 350 |
+
"type": "test",
|
| 351 |
+
"message": "This is a test webhook from Cidadão.AI",
|
| 352 |
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
| 353 |
+
},
|
| 354 |
+
metadata={"test": True}
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
return await self._send_webhook(webhook, test_payload)
|
| 358 |
+
|
| 359 |
+
async def close(self):
|
| 360 |
+
"""Close the webhook service."""
|
| 361 |
+
await self._client.aclose()
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# Singleton instance
|
| 365 |
+
webhook_service = WebhookService()
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
# Convenience functions
|
| 369 |
+
async def send_webhook_event(
|
| 370 |
+
event: WebhookEvent,
|
| 371 |
+
data: Dict[str, Any],
|
| 372 |
+
metadata: Optional[Dict[str, Any]] = None
|
| 373 |
+
) -> List[WebhookDelivery]:
|
| 374 |
+
"""Send a webhook event using the default service."""
|
| 375 |
+
return await webhook_service.send_event(event, data, metadata)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
async def register_webhook(
|
| 379 |
+
url: str,
|
| 380 |
+
events: Optional[List[WebhookEvent]] = None,
|
| 381 |
+
secret: Optional[str] = None,
|
| 382 |
+
**kwargs
|
| 383 |
+
) -> None:
|
| 384 |
+
"""Register a new webhook."""
|
| 385 |
+
config = WebhookConfig(
|
| 386 |
+
url=url,
|
| 387 |
+
events=events,
|
| 388 |
+
secret=secret,
|
| 389 |
+
**kwargs
|
| 390 |
+
)
|
| 391 |
+
webhook_service.add_webhook(config)
|
src/templates/email/anomaly_alert.html
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2 style="color: #e74c3c;">⚠️ Alerta de Anomalia Detectada</h2>
|
| 5 |
+
|
| 6 |
+
<div class="alert alert-danger">
|
| 7 |
+
<strong>ATENÇÃO:</strong> Uma anomalia {{ severity | default('significativa') }} foi detectada em nossa análise.
|
| 8 |
+
</div>
|
| 9 |
+
|
| 10 |
+
<div style="margin: 20px 0;">
|
| 11 |
+
<h3>Detalhes da Anomalia</h3>
|
| 12 |
+
<table style="width: 100%; border-collapse: collapse;">
|
| 13 |
+
<tr>
|
| 14 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;"><strong>Tipo:</strong></td>
|
| 15 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;">{{ anomaly_type }}</td>
|
| 16 |
+
</tr>
|
| 17 |
+
<tr>
|
| 18 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;"><strong>Severidade:</strong></td>
|
| 19 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;">
|
| 20 |
+
<span style="color: {% if severity == 'critical' %}#e74c3c{% elif severity == 'high' %}#f39c12{% elif severity == 'medium' %}#f1c40f{% else %}#3498db{% endif %};">
|
| 21 |
+
{{ severity | upper }}
|
| 22 |
+
</span>
|
| 23 |
+
</td>
|
| 24 |
+
</tr>
|
| 25 |
+
<tr>
|
| 26 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;"><strong>Data de Detecção:</strong></td>
|
| 27 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;">{{ detection_date }}</td>
|
| 28 |
+
</tr>
|
| 29 |
+
<tr>
|
| 30 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;"><strong>Fonte de Dados:</strong></td>
|
| 31 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;">{{ data_source }}</td>
|
| 32 |
+
</tr>
|
| 33 |
+
{% if confidence_score %}
|
| 34 |
+
<tr>
|
| 35 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;"><strong>Confiança:</strong></td>
|
| 36 |
+
<td style="padding: 10px; border-bottom: 1px solid #ecf0f1;">{{ confidence_score }}%</td>
|
| 37 |
+
</tr>
|
| 38 |
+
{% endif %}
|
| 39 |
+
</table>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
{% if description %}
|
| 43 |
+
<div style="margin: 20px 0;">
|
| 44 |
+
<h3>Descrição</h3>
|
| 45 |
+
<p>{{ description }}</p>
|
| 46 |
+
</div>
|
| 47 |
+
{% endif %}
|
| 48 |
+
|
| 49 |
+
{% if affected_entities %}
|
| 50 |
+
<div style="margin: 20px 0;">
|
| 51 |
+
<h3>Entidades Afetadas</h3>
|
| 52 |
+
<ul>
|
| 53 |
+
{% for entity in affected_entities %}
|
| 54 |
+
<li>{{ entity }}</li>
|
| 55 |
+
{% endfor %}
|
| 56 |
+
</ul>
|
| 57 |
+
</div>
|
| 58 |
+
{% endif %}
|
| 59 |
+
|
| 60 |
+
{% if potential_impact %}
|
| 61 |
+
<div style="background-color: #fff3cd; padding: 15px; border-left: 4px solid #f39c12; margin: 20px 0;">
|
| 62 |
+
<h4 style="margin-top: 0;">Impacto Potencial</h4>
|
| 63 |
+
<p>{{ potential_impact }}</p>
|
| 64 |
+
</div>
|
| 65 |
+
{% endif %}
|
| 66 |
+
|
| 67 |
+
{% if recommended_actions %}
|
| 68 |
+
<div style="margin: 20px 0;">
|
| 69 |
+
<h3>Ações Recomendadas</h3>
|
| 70 |
+
<ol>
|
| 71 |
+
{% for action in recommended_actions %}
|
| 72 |
+
<li>{{ action }}</li>
|
| 73 |
+
{% endfor %}
|
| 74 |
+
</ol>
|
| 75 |
+
</div>
|
| 76 |
+
{% endif %}
|
| 77 |
+
|
| 78 |
+
<p style="text-align: center; margin: 30px 0;">
|
| 79 |
+
<a href="{{ app_url }}/anomalies/{{ anomaly_id }}" class="button" style="background-color: #e74c3c;">
|
| 80 |
+
Investigar Anomalia
|
| 81 |
+
</a>
|
| 82 |
+
</p>
|
| 83 |
+
|
| 84 |
+
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ecf0f1;">
|
| 85 |
+
|
| 86 |
+
<p style="background-color: #f8f9fa; padding: 15px; border-radius: 5px;">
|
| 87 |
+
<strong>Nota:</strong> Este alerta foi gerado automaticamente pelo sistema de detecção de anomalias do {{ app_name }}.
|
| 88 |
+
É importante revisar e validar estas informações antes de tomar qualquer ação.
|
| 89 |
+
</p>
|
| 90 |
+
|
| 91 |
+
<p style="color: #7f8c8d; font-size: 14px;">
|
| 92 |
+
Para ajustar seus alertas de anomalias, acesse as configurações de notificação em sua conta.
|
| 93 |
+
</p>
|
| 94 |
+
{% endblock %}
|
src/templates/email/base.html
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="pt-BR">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{% block title %}{{ subject }}{% endblock %}</title>
|
| 7 |
+
<style>
|
| 8 |
+
body {
|
| 9 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
| 10 |
+
line-height: 1.6;
|
| 11 |
+
color: #333;
|
| 12 |
+
background-color: #f4f4f4;
|
| 13 |
+
margin: 0;
|
| 14 |
+
padding: 0;
|
| 15 |
+
}
|
| 16 |
+
.container {
|
| 17 |
+
max-width: 600px;
|
| 18 |
+
margin: 0 auto;
|
| 19 |
+
background-color: #ffffff;
|
| 20 |
+
padding: 0;
|
| 21 |
+
}
|
| 22 |
+
.header {
|
| 23 |
+
background-color: #2c3e50;
|
| 24 |
+
color: #ffffff;
|
| 25 |
+
padding: 20px;
|
| 26 |
+
text-align: center;
|
| 27 |
+
}
|
| 28 |
+
.header h1 {
|
| 29 |
+
margin: 0;
|
| 30 |
+
font-size: 24px;
|
| 31 |
+
}
|
| 32 |
+
.content {
|
| 33 |
+
padding: 30px;
|
| 34 |
+
}
|
| 35 |
+
.button {
|
| 36 |
+
display: inline-block;
|
| 37 |
+
padding: 12px 24px;
|
| 38 |
+
background-color: #3498db;
|
| 39 |
+
color: #ffffff;
|
| 40 |
+
text-decoration: none;
|
| 41 |
+
border-radius: 5px;
|
| 42 |
+
margin: 10px 0;
|
| 43 |
+
}
|
| 44 |
+
.button:hover {
|
| 45 |
+
background-color: #2980b9;
|
| 46 |
+
}
|
| 47 |
+
.footer {
|
| 48 |
+
background-color: #ecf0f1;
|
| 49 |
+
padding: 20px;
|
| 50 |
+
text-align: center;
|
| 51 |
+
font-size: 14px;
|
| 52 |
+
color: #7f8c8d;
|
| 53 |
+
}
|
| 54 |
+
.footer a {
|
| 55 |
+
color: #3498db;
|
| 56 |
+
text-decoration: none;
|
| 57 |
+
}
|
| 58 |
+
.alert {
|
| 59 |
+
padding: 15px;
|
| 60 |
+
margin: 20px 0;
|
| 61 |
+
border-radius: 5px;
|
| 62 |
+
}
|
| 63 |
+
.alert-warning {
|
| 64 |
+
background-color: #f39c12;
|
| 65 |
+
color: #ffffff;
|
| 66 |
+
}
|
| 67 |
+
.alert-danger {
|
| 68 |
+
background-color: #e74c3c;
|
| 69 |
+
color: #ffffff;
|
| 70 |
+
}
|
| 71 |
+
.alert-success {
|
| 72 |
+
background-color: #27ae60;
|
| 73 |
+
color: #ffffff;
|
| 74 |
+
}
|
| 75 |
+
.alert-info {
|
| 76 |
+
background-color: #3498db;
|
| 77 |
+
color: #ffffff;
|
| 78 |
+
}
|
| 79 |
+
pre {
|
| 80 |
+
background-color: #f4f4f4;
|
| 81 |
+
padding: 10px;
|
| 82 |
+
border-radius: 5px;
|
| 83 |
+
overflow-x: auto;
|
| 84 |
+
}
|
| 85 |
+
code {
|
| 86 |
+
font-family: 'Courier New', monospace;
|
| 87 |
+
background-color: #f4f4f4;
|
| 88 |
+
padding: 2px 4px;
|
| 89 |
+
border-radius: 3px;
|
| 90 |
+
}
|
| 91 |
+
</style>
|
| 92 |
+
{% block extra_style %}{% endblock %}
|
| 93 |
+
</head>
|
| 94 |
+
<body>
|
| 95 |
+
<div class="container">
|
| 96 |
+
<div class="header">
|
| 97 |
+
<h1>{{ app_name }}</h1>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div class="content">
|
| 101 |
+
{% block content %}{% endblock %}
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<div class="footer">
|
| 105 |
+
<p>© 2025 {{ app_name }}. Todos os direitos reservados.</p>
|
| 106 |
+
<p>
|
| 107 |
+
<a href="{{ app_url }}">Visite nosso site</a> |
|
| 108 |
+
<a href="mailto:{{ support_email }}">Contato</a>
|
| 109 |
+
</p>
|
| 110 |
+
<p style="margin-top: 20px; font-size: 12px;">
|
| 111 |
+
Este é um e-mail automático. Por favor, não responda diretamente a este endereço.
|
| 112 |
+
</p>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</body>
|
| 116 |
+
</html>
|
src/templates/email/investigation_complete.html
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2>Investigação Concluída</h2>
|
| 5 |
+
|
| 6 |
+
<p>Olá,</p>
|
| 7 |
+
|
| 8 |
+
<p>Sua investigação foi concluída com sucesso. Aqui está um resumo dos resultados:</p>
|
| 9 |
+
|
| 10 |
+
<div style="background-color: #ecf0f1; padding: 20px; border-radius: 5px; margin: 20px 0;">
|
| 11 |
+
<h3>Resumo da Investigação</h3>
|
| 12 |
+
<ul>
|
| 13 |
+
<li><strong>ID da Investigação:</strong> {{ investigation_id }}</li>
|
| 14 |
+
<li><strong>Data de Início:</strong> {{ start_date }}</li>
|
| 15 |
+
<li><strong>Data de Conclusão:</strong> {{ end_date }}</li>
|
| 16 |
+
<li><strong>Duração:</strong> {{ duration }}</li>
|
| 17 |
+
<li><strong>Status:</strong> <span style="color: #27ae60;">Concluída</span></li>
|
| 18 |
+
</ul>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
{% if summary %}
|
| 22 |
+
<div style="margin: 20px 0;">
|
| 23 |
+
<h3>Resumo Executivo</h3>
|
| 24 |
+
<p>{{ summary }}</p>
|
| 25 |
+
</div>
|
| 26 |
+
{% endif %}
|
| 27 |
+
|
| 28 |
+
{% if anomalies_count %}
|
| 29 |
+
<div class="alert alert-warning">
|
| 30 |
+
<strong>Anomalias Detectadas:</strong> {{ anomalies_count }}
|
| 31 |
+
{% if critical_anomalies %}
|
| 32 |
+
<br><strong>Anomalias Críticas:</strong> {{ critical_anomalies }}
|
| 33 |
+
{% endif %}
|
| 34 |
+
</div>
|
| 35 |
+
{% endif %}
|
| 36 |
+
|
| 37 |
+
{% if findings %}
|
| 38 |
+
<div style="margin: 20px 0;">
|
| 39 |
+
<h3>Principais Descobertas</h3>
|
| 40 |
+
<ul>
|
| 41 |
+
{% for finding in findings %}
|
| 42 |
+
<li>{{ finding }}</li>
|
| 43 |
+
{% endfor %}
|
| 44 |
+
</ul>
|
| 45 |
+
</div>
|
| 46 |
+
{% endif %}
|
| 47 |
+
|
| 48 |
+
{% if recommendations %}
|
| 49 |
+
<div style="margin: 20px 0;">
|
| 50 |
+
<h3>Recomendações</h3>
|
| 51 |
+
<ol>
|
| 52 |
+
{% for recommendation in recommendations %}
|
| 53 |
+
<li>{{ recommendation }}</li>
|
| 54 |
+
{% endfor %}
|
| 55 |
+
</ol>
|
| 56 |
+
</div>
|
| 57 |
+
{% endif %}
|
| 58 |
+
|
| 59 |
+
<p style="text-align: center; margin: 30px 0;">
|
| 60 |
+
<a href="{{ app_url }}/investigations/{{ investigation_id }}" class="button">Ver Relatório Completo</a>
|
| 61 |
+
</p>
|
| 62 |
+
|
| 63 |
+
{% if confidence_score %}
|
| 64 |
+
<p style="text-align: center; color: #7f8c8d;">
|
| 65 |
+
<small>Confiança na Análise: {{ confidence_score }}%</small>
|
| 66 |
+
</p>
|
| 67 |
+
{% endif %}
|
| 68 |
+
|
| 69 |
+
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ecf0f1;">
|
| 70 |
+
|
| 71 |
+
<p style="color: #7f8c8d; font-size: 14px;">
|
| 72 |
+
Esta investigação foi realizada pelos agentes de IA do {{ app_name }}.
|
| 73 |
+
Para mais informações sobre nossa metodologia, visite nossa documentação.
|
| 74 |
+
</p>
|
| 75 |
+
{% endblock %}
|
src/templates/email/notification.html
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block content %}
|
| 4 |
+
<h2>{{ title }}</h2>
|
| 5 |
+
|
| 6 |
+
{% if message %}
|
| 7 |
+
<p>{{ message }}</p>
|
| 8 |
+
{% endif %}
|
| 9 |
+
|
| 10 |
+
{% if details %}
|
| 11 |
+
<div class="details">
|
| 12 |
+
<h3>Detalhes:</h3>
|
| 13 |
+
<ul>
|
| 14 |
+
{% for key, value in details.items() %}
|
| 15 |
+
<li><strong>{{ key }}:</strong> {{ value }}</li>
|
| 16 |
+
{% endfor %}
|
| 17 |
+
</ul>
|
| 18 |
+
</div>
|
| 19 |
+
{% endif %}
|
| 20 |
+
|
| 21 |
+
{% if action_url %}
|
| 22 |
+
<p style="text-align: center; margin: 30px 0;">
|
| 23 |
+
<a href="{{ action_url }}" class="button">{{ action_text | default('Ver Detalhes') }}</a>
|
| 24 |
+
</p>
|
| 25 |
+
{% endif %}
|
| 26 |
+
|
| 27 |
+
{% if severity %}
|
| 28 |
+
<div class="alert alert-{{ severity }}">
|
| 29 |
+
<strong>Nível de Severidade:</strong> {{ severity | upper }}
|
| 30 |
+
</div>
|
| 31 |
+
{% endif %}
|
| 32 |
+
|
| 33 |
+
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ecf0f1;">
|
| 34 |
+
|
| 35 |
+
<p style="color: #7f8c8d; font-size: 14px;">
|
| 36 |
+
Você está recebendo este e-mail porque está inscrito para receber notificações do {{ app_name }}.
|
| 37 |
+
Para ajustar suas preferências de notificação, acesse sua conta em nosso sistema.
|
| 38 |
+
</p>
|
| 39 |
+
{% endblock %}
|
src/templates/email/notification.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{{ title }}
|
| 2 |
+
{{ '=' * title|length }}
|
| 3 |
+
|
| 4 |
+
{{ message }}
|
| 5 |
+
|
| 6 |
+
{% if details %}
|
| 7 |
+
Detalhes:
|
| 8 |
+
{% for key, value in details.items() %}
|
| 9 |
+
- {{ key }}: {{ value }}
|
| 10 |
+
{% endfor %}
|
| 11 |
+
{% endif %}
|
| 12 |
+
|
| 13 |
+
{% if action_url %}
|
| 14 |
+
{{ action_text | default('Ver Detalhes') }}: {{ action_url }}
|
| 15 |
+
{% endif %}
|
| 16 |
+
|
| 17 |
+
{% if severity %}
|
| 18 |
+
Nível de Severidade: {{ severity | upper }}
|
| 19 |
+
{% endif %}
|
| 20 |
+
|
| 21 |
+
--
|
| 22 |
+
{{ app_name }}
|
| 23 |
+
{{ app_url }}
|
| 24 |
+
|
| 25 |
+
Você está recebendo este e-mail porque está inscrito para receber notificações do {{ app_name }}.
|
| 26 |
+
Para ajustar suas preferências de notificação, acesse sua conta em nosso sistema.
|
| 27 |
+
|
| 28 |
+
Este é um e-mail automático. Por favor, não responda diretamente a este endereço.
|