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 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.