""" Unified Inbox Mail Database Service PostgreSQL database operations for multi-account mail aggregation. """ import os import json import uuid from typing import Optional, List, Dict, Any from datetime import datetime, timedelta # Database Configuration - from Vault or environment (test default for CI) DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://test:test@localhost:5432/test") # Flag to check if using test defaults _DB_CONFIGURED = DATABASE_URL != "postgresql://test:test@localhost:5432/test" # Connection pool (shared with metrics_db) _pool = None async def get_pool(): """Get or create database connection pool.""" global _pool if _pool is None: try: import asyncpg _pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) except ImportError: print("Warning: asyncpg not installed. Mail database disabled.") return None except Exception as e: print(f"Warning: Failed to connect to PostgreSQL: {e}") return None return _pool async def init_mail_tables() -> bool: """Initialize mail tables in PostgreSQL.""" pool = await get_pool() if pool is None: return False create_tables_sql = """ -- ============================================================================= -- External Email Accounts -- ============================================================================= CREATE TABLE IF NOT EXISTS external_email_accounts ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, tenant_id VARCHAR(36) NOT NULL, email VARCHAR(255) NOT NULL, display_name VARCHAR(255), account_type VARCHAR(50) DEFAULT 'personal', -- IMAP Settings (password stored in Vault) imap_host VARCHAR(255) NOT NULL, imap_port INTEGER DEFAULT 993, imap_ssl BOOLEAN DEFAULT TRUE, -- SMTP Settings smtp_host VARCHAR(255) NOT NULL, smtp_port INTEGER DEFAULT 465, smtp_ssl BOOLEAN DEFAULT TRUE, -- Vault path for credentials vault_path VARCHAR(500), -- Status tracking status VARCHAR(20) DEFAULT 'pending', last_sync TIMESTAMP, sync_error TEXT, email_count INTEGER DEFAULT 0, unread_count INTEGER DEFAULT 0, -- Timestamps created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), -- Constraints UNIQUE(user_id, email) ); CREATE INDEX IF NOT EXISTS idx_mail_accounts_user ON external_email_accounts(user_id); CREATE INDEX IF NOT EXISTS idx_mail_accounts_tenant ON external_email_accounts(tenant_id); CREATE INDEX IF NOT EXISTS idx_mail_accounts_status ON external_email_accounts(status); -- ============================================================================= -- Aggregated Emails -- ============================================================================= CREATE TABLE IF NOT EXISTS aggregated_emails ( id VARCHAR(36) PRIMARY KEY, account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE CASCADE, user_id VARCHAR(36) NOT NULL, tenant_id VARCHAR(36) NOT NULL, -- Email identification message_id VARCHAR(500) NOT NULL, folder VARCHAR(100) DEFAULT 'INBOX', -- Email content subject TEXT, sender_email VARCHAR(255), sender_name VARCHAR(255), recipients JSONB DEFAULT '[]', cc JSONB DEFAULT '[]', body_preview TEXT, body_text TEXT, body_html TEXT, has_attachments BOOLEAN DEFAULT FALSE, attachments JSONB DEFAULT '[]', headers JSONB DEFAULT '{}', -- Status flags is_read BOOLEAN DEFAULT FALSE, is_starred BOOLEAN DEFAULT FALSE, is_deleted BOOLEAN DEFAULT FALSE, -- Dates date_sent TIMESTAMP, date_received TIMESTAMP, -- AI enrichment category VARCHAR(50), sender_type VARCHAR(50), sender_authority_name VARCHAR(255), detected_deadlines JSONB DEFAULT '[]', suggested_priority VARCHAR(20), ai_summary TEXT, ai_analyzed_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW(), -- Prevent duplicate imports UNIQUE(account_id, message_id) ); CREATE INDEX IF NOT EXISTS idx_emails_account ON aggregated_emails(account_id); CREATE INDEX IF NOT EXISTS idx_emails_user ON aggregated_emails(user_id); CREATE INDEX IF NOT EXISTS idx_emails_tenant ON aggregated_emails(tenant_id); CREATE INDEX IF NOT EXISTS idx_emails_date ON aggregated_emails(date_received DESC); CREATE INDEX IF NOT EXISTS idx_emails_category ON aggregated_emails(category); CREATE INDEX IF NOT EXISTS idx_emails_unread ON aggregated_emails(is_read) WHERE is_read = FALSE; CREATE INDEX IF NOT EXISTS idx_emails_starred ON aggregated_emails(is_starred) WHERE is_starred = TRUE; CREATE INDEX IF NOT EXISTS idx_emails_sender ON aggregated_emails(sender_email); -- ============================================================================= -- Inbox Tasks (Arbeitsvorrat) -- ============================================================================= CREATE TABLE IF NOT EXISTS inbox_tasks ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, tenant_id VARCHAR(36) NOT NULL, email_id VARCHAR(36) REFERENCES aggregated_emails(id) ON DELETE SET NULL, account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE SET NULL, -- Task content title VARCHAR(500) NOT NULL, description TEXT, priority VARCHAR(20) DEFAULT 'medium', status VARCHAR(20) DEFAULT 'pending', deadline TIMESTAMP, -- Source information source_email_subject TEXT, source_sender VARCHAR(255), source_sender_type VARCHAR(50), -- AI extraction info ai_extracted BOOLEAN DEFAULT FALSE, confidence_score FLOAT, -- Completion tracking completed_at TIMESTAMP, reminder_at TIMESTAMP, -- Timestamps created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_tasks_user ON inbox_tasks(user_id); CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON inbox_tasks(tenant_id); CREATE INDEX IF NOT EXISTS idx_tasks_status ON inbox_tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_deadline ON inbox_tasks(deadline) WHERE deadline IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_tasks_priority ON inbox_tasks(priority); CREATE INDEX IF NOT EXISTS idx_tasks_email ON inbox_tasks(email_id) WHERE email_id IS NOT NULL; -- ============================================================================= -- Email Templates -- ============================================================================= CREATE TABLE IF NOT EXISTS email_templates ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), -- NULL for system templates tenant_id VARCHAR(36), name VARCHAR(255) NOT NULL, category VARCHAR(100), subject_template TEXT, body_template TEXT, variables JSONB DEFAULT '[]', is_system BOOLEAN DEFAULT FALSE, usage_count INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_templates_user ON email_templates(user_id); CREATE INDEX IF NOT EXISTS idx_templates_tenant ON email_templates(tenant_id); CREATE INDEX IF NOT EXISTS idx_templates_system ON email_templates(is_system); -- ============================================================================= -- Mail Audit Log -- ============================================================================= CREATE TABLE IF NOT EXISTS mail_audit_log ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, tenant_id VARCHAR(36), action VARCHAR(100) NOT NULL, entity_type VARCHAR(50), -- account, email, task entity_id VARCHAR(36), details JSONB, ip_address VARCHAR(45), user_agent TEXT, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_mail_audit_user ON mail_audit_log(user_id); CREATE INDEX IF NOT EXISTS idx_mail_audit_created ON mail_audit_log(created_at DESC); CREATE INDEX IF NOT EXISTS idx_mail_audit_action ON mail_audit_log(action); -- ============================================================================= -- Sync Status Tracking -- ============================================================================= CREATE TABLE IF NOT EXISTS mail_sync_status ( id VARCHAR(36) PRIMARY KEY, account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE CASCADE, folder VARCHAR(100), last_uid INTEGER DEFAULT 0, last_sync TIMESTAMP, sync_errors INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(account_id, folder) ); """ try: async with pool.acquire() as conn: await conn.execute(create_tables_sql) print("Mail tables initialized successfully") return True except Exception as e: print(f"Failed to initialize mail tables: {e}") return False # ============================================================================= # Email Account Operations # ============================================================================= async def create_email_account( user_id: str, tenant_id: str, email: str, display_name: str, account_type: str, imap_host: str, imap_port: int, imap_ssl: bool, smtp_host: str, smtp_port: int, smtp_ssl: bool, vault_path: str, ) -> Optional[str]: """Create a new email account. Returns the account ID.""" pool = await get_pool() if pool is None: return None account_id = str(uuid.uuid4()) try: async with pool.acquire() as conn: await conn.execute( """ INSERT INTO external_email_accounts (id, user_id, tenant_id, email, display_name, account_type, imap_host, imap_port, imap_ssl, smtp_host, smtp_port, smtp_ssl, vault_path) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) """, account_id, user_id, tenant_id, email, display_name, account_type, imap_host, imap_port, imap_ssl, smtp_host, smtp_port, smtp_ssl, vault_path ) return account_id except Exception as e: print(f"Failed to create email account: {e}") return None async def get_email_accounts( user_id: str, tenant_id: Optional[str] = None, ) -> List[Dict]: """Get all email accounts for a user.""" pool = await get_pool() if pool is None: return [] try: async with pool.acquire() as conn: if tenant_id: rows = await conn.fetch( """ SELECT * FROM external_email_accounts WHERE user_id = $1 AND tenant_id = $2 ORDER BY created_at """, user_id, tenant_id ) else: rows = await conn.fetch( """ SELECT * FROM external_email_accounts WHERE user_id = $1 ORDER BY created_at """, user_id ) return [dict(r) for r in rows] except Exception as e: print(f"Failed to get email accounts: {e}") return [] async def get_email_account(account_id: str, user_id: str) -> Optional[Dict]: """Get a single email account.""" pool = await get_pool() if pool is None: return None try: async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT * FROM external_email_accounts WHERE id = $1 AND user_id = $2 """, account_id, user_id ) return dict(row) if row else None except Exception as e: print(f"Failed to get email account: {e}") return None async def update_account_status( account_id: str, status: str, sync_error: Optional[str] = None, email_count: Optional[int] = None, unread_count: Optional[int] = None, ) -> bool: """Update account sync status.""" pool = await get_pool() if pool is None: return False try: async with pool.acquire() as conn: await conn.execute( """ UPDATE external_email_accounts SET status = $2, sync_error = $3, email_count = COALESCE($4, email_count), unread_count = COALESCE($5, unread_count), last_sync = NOW(), updated_at = NOW() WHERE id = $1 """, account_id, status, sync_error, email_count, unread_count ) return True except Exception as e: print(f"Failed to update account status: {e}") return False async def delete_email_account(account_id: str, user_id: str) -> bool: """Delete an email account (cascades to emails).""" pool = await get_pool() if pool is None: return False try: async with pool.acquire() as conn: result = await conn.execute( """ DELETE FROM external_email_accounts WHERE id = $1 AND user_id = $2 """, account_id, user_id ) return "DELETE" in result except Exception as e: print(f"Failed to delete email account: {e}") return False # ============================================================================= # Aggregated Email Operations # ============================================================================= async def upsert_email( account_id: str, user_id: str, tenant_id: str, message_id: str, subject: str, sender_email: str, sender_name: Optional[str], recipients: List[str], cc: List[str], body_preview: Optional[str], body_text: Optional[str], body_html: Optional[str], has_attachments: bool, attachments: List[Dict], headers: Dict, folder: str, date_sent: datetime, date_received: datetime, ) -> Optional[str]: """Insert or update an email. Returns the email ID.""" pool = await get_pool() if pool is None: return None email_id = str(uuid.uuid4()) try: async with pool.acquire() as conn: # Try insert, on conflict update (for re-sync scenarios) row = await conn.fetchrow( """ INSERT INTO aggregated_emails (id, account_id, user_id, tenant_id, message_id, subject, sender_email, sender_name, recipients, cc, body_preview, body_text, body_html, has_attachments, attachments, headers, folder, date_sent, date_received) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) ON CONFLICT (account_id, message_id) DO UPDATE SET subject = EXCLUDED.subject, is_read = EXCLUDED.is_read, folder = EXCLUDED.folder RETURNING id """, email_id, account_id, user_id, tenant_id, message_id, subject, sender_email, sender_name, json.dumps(recipients), json.dumps(cc), body_preview, body_text, body_html, has_attachments, json.dumps(attachments), json.dumps(headers), folder, date_sent, date_received ) return row['id'] if row else None except Exception as e: print(f"Failed to upsert email: {e}") return None async def get_unified_inbox( user_id: str, account_ids: Optional[List[str]] = None, categories: Optional[List[str]] = None, is_read: Optional[bool] = None, is_starred: Optional[bool] = None, limit: int = 50, offset: int = 0, ) -> List[Dict]: """Get unified inbox with filtering.""" pool = await get_pool() if pool is None: return [] try: async with pool.acquire() as conn: # Build dynamic query conditions = ["user_id = $1", "is_deleted = FALSE"] params = [user_id] param_idx = 2 if account_ids: conditions.append(f"account_id = ANY(${param_idx})") params.append(account_ids) param_idx += 1 if categories: conditions.append(f"category = ANY(${param_idx})") params.append(categories) param_idx += 1 if is_read is not None: conditions.append(f"is_read = ${param_idx}") params.append(is_read) param_idx += 1 if is_starred is not None: conditions.append(f"is_starred = ${param_idx}") params.append(is_starred) param_idx += 1 where_clause = " AND ".join(conditions) params.extend([limit, offset]) query = f""" SELECT e.*, a.email as account_email, a.display_name as account_name FROM aggregated_emails e JOIN external_email_accounts a ON e.account_id = a.id WHERE {where_clause} ORDER BY e.date_received DESC LIMIT ${param_idx} OFFSET ${param_idx + 1} """ rows = await conn.fetch(query, *params) return [dict(r) for r in rows] except Exception as e: print(f"Failed to get unified inbox: {e}") return [] async def get_email(email_id: str, user_id: str) -> Optional[Dict]: """Get a single email by ID.""" pool = await get_pool() if pool is None: return None try: async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT e.*, a.email as account_email, a.display_name as account_name FROM aggregated_emails e JOIN external_email_accounts a ON e.account_id = a.id WHERE e.id = $1 AND e.user_id = $2 """, email_id, user_id ) return dict(row) if row else None except Exception as e: print(f"Failed to get email: {e}") return None async def update_email_ai_analysis( email_id: str, category: str, sender_type: str, sender_authority_name: Optional[str], detected_deadlines: List[Dict], suggested_priority: str, ai_summary: Optional[str], ) -> bool: """Update email with AI analysis results.""" pool = await get_pool() if pool is None: return False try: async with pool.acquire() as conn: await conn.execute( """ UPDATE aggregated_emails SET category = $2, sender_type = $3, sender_authority_name = $4, detected_deadlines = $5, suggested_priority = $6, ai_summary = $7, ai_analyzed_at = NOW() WHERE id = $1 """, email_id, category, sender_type, sender_authority_name, json.dumps(detected_deadlines), suggested_priority, ai_summary ) return True except Exception as e: print(f"Failed to update email AI analysis: {e}") return False async def mark_email_read(email_id: str, user_id: str, is_read: bool = True) -> bool: """Mark email as read/unread.""" pool = await get_pool() if pool is None: return False try: async with pool.acquire() as conn: await conn.execute( """ UPDATE aggregated_emails SET is_read = $3 WHERE id = $1 AND user_id = $2 """, email_id, user_id, is_read ) return True except Exception as e: print(f"Failed to mark email read: {e}") return False async def mark_email_starred(email_id: str, user_id: str, is_starred: bool = True) -> bool: """Mark email as starred/unstarred.""" pool = await get_pool() if pool is None: return False try: async with pool.acquire() as conn: await conn.execute( """ UPDATE aggregated_emails SET is_starred = $3 WHERE id = $1 AND user_id = $2 """, email_id, user_id, is_starred ) return True except Exception as e: print(f"Failed to mark email starred: {e}") return False # ============================================================================= # Inbox Task Operations # ============================================================================= async def create_task( user_id: str, tenant_id: str, title: str, description: Optional[str] = None, priority: str = "medium", deadline: Optional[datetime] = None, email_id: Optional[str] = None, account_id: Optional[str] = None, source_email_subject: Optional[str] = None, source_sender: Optional[str] = None, source_sender_type: Optional[str] = None, ai_extracted: bool = False, confidence_score: Optional[float] = None, ) -> Optional[str]: """Create a new inbox task.""" pool = await get_pool() if pool is None: return None task_id = str(uuid.uuid4()) try: async with pool.acquire() as conn: await conn.execute( """ INSERT INTO inbox_tasks (id, user_id, tenant_id, title, description, priority, deadline, email_id, account_id, source_email_subject, source_sender, source_sender_type, ai_extracted, confidence_score) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) """, task_id, user_id, tenant_id, title, description, priority, deadline, email_id, account_id, source_email_subject, source_sender, source_sender_type, ai_extracted, confidence_score ) return task_id except Exception as e: print(f"Failed to create task: {e}") return None async def get_tasks( user_id: str, status: Optional[str] = None, priority: Optional[str] = None, include_completed: bool = False, limit: int = 50, offset: int = 0, ) -> List[Dict]: """Get tasks for a user.""" pool = await get_pool() if pool is None: return [] try: async with pool.acquire() as conn: conditions = ["user_id = $1"] params = [user_id] param_idx = 2 if not include_completed: conditions.append("status != 'completed'") if status: conditions.append(f"status = ${param_idx}") params.append(status) param_idx += 1 if priority: conditions.append(f"priority = ${param_idx}") params.append(priority) param_idx += 1 where_clause = " AND ".join(conditions) params.extend([limit, offset]) query = f""" SELECT * FROM inbox_tasks WHERE {where_clause} ORDER BY CASE priority WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 END, deadline ASC NULLS LAST, created_at DESC LIMIT ${param_idx} OFFSET ${param_idx + 1} """ rows = await conn.fetch(query, *params) return [dict(r) for r in rows] except Exception as e: print(f"Failed to get tasks: {e}") return [] async def get_task(task_id: str, user_id: str) -> Optional[Dict]: """Get a single task.""" pool = await get_pool() if pool is None: return None try: async with pool.acquire() as conn: row = await conn.fetchrow( "SELECT * FROM inbox_tasks WHERE id = $1 AND user_id = $2", task_id, user_id ) return dict(row) if row else None except Exception as e: print(f"Failed to get task: {e}") return None async def update_task( task_id: str, user_id: str, title: Optional[str] = None, description: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, deadline: Optional[datetime] = None, ) -> bool: """Update a task.""" pool = await get_pool() if pool is None: return False try: async with pool.acquire() as conn: # Build dynamic update updates = ["updated_at = NOW()"] params = [task_id, user_id] param_idx = 3 if title is not None: updates.append(f"title = ${param_idx}") params.append(title) param_idx += 1 if description is not None: updates.append(f"description = ${param_idx}") params.append(description) param_idx += 1 if priority is not None: updates.append(f"priority = ${param_idx}") params.append(priority) param_idx += 1 if status is not None: updates.append(f"status = ${param_idx}") params.append(status) param_idx += 1 if status == "completed": updates.append("completed_at = NOW()") if deadline is not None: updates.append(f"deadline = ${param_idx}") params.append(deadline) param_idx += 1 set_clause = ", ".join(updates) await conn.execute( f"UPDATE inbox_tasks SET {set_clause} WHERE id = $1 AND user_id = $2", *params ) return True except Exception as e: print(f"Failed to update task: {e}") return False async def get_task_dashboard_stats(user_id: str) -> Dict: """Get dashboard statistics for tasks.""" pool = await get_pool() if pool is None: return {} try: async with pool.acquire() as conn: now = datetime.now() today_end = now.replace(hour=23, minute=59, second=59) week_end = now + timedelta(days=7) stats = await conn.fetchrow( """ SELECT COUNT(*) as total_tasks, COUNT(*) FILTER (WHERE status = 'pending') as pending_tasks, COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress_tasks, COUNT(*) FILTER (WHERE status = 'completed') as completed_tasks, COUNT(*) FILTER (WHERE status != 'completed' AND deadline < $2) as overdue_tasks, COUNT(*) FILTER (WHERE status != 'completed' AND deadline <= $3) as due_today, COUNT(*) FILTER (WHERE status != 'completed' AND deadline <= $4) as due_this_week FROM inbox_tasks WHERE user_id = $1 """, user_id, now, today_end, week_end ) by_priority = await conn.fetch( """ SELECT priority, COUNT(*) as count FROM inbox_tasks WHERE user_id = $1 AND status != 'completed' GROUP BY priority """, user_id ) by_sender = await conn.fetch( """ SELECT source_sender_type, COUNT(*) as count FROM inbox_tasks WHERE user_id = $1 AND status != 'completed' AND source_sender_type IS NOT NULL GROUP BY source_sender_type """, user_id ) return { "total_tasks": stats['total_tasks'] or 0, "pending_tasks": stats['pending_tasks'] or 0, "in_progress_tasks": stats['in_progress_tasks'] or 0, "completed_tasks": stats['completed_tasks'] or 0, "overdue_tasks": stats['overdue_tasks'] or 0, "due_today": stats['due_today'] or 0, "due_this_week": stats['due_this_week'] or 0, "by_priority": {r['priority']: r['count'] for r in by_priority}, "by_sender_type": {r['source_sender_type']: r['count'] for r in by_sender}, } except Exception as e: print(f"Failed to get task stats: {e}") return {} # ============================================================================= # Statistics & Audit # ============================================================================= async def get_mail_stats(user_id: str) -> Dict: """Get overall mail statistics for a user.""" pool = await get_pool() if pool is None: return {} try: async with pool.acquire() as conn: today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) # Account stats accounts = await conn.fetch( """ SELECT id, email, display_name, status, email_count, unread_count, last_sync FROM external_email_accounts WHERE user_id = $1 """, user_id ) # Email counts email_stats = await conn.fetchrow( """ SELECT COUNT(*) as total_emails, COUNT(*) FILTER (WHERE is_read = FALSE) as unread_emails, COUNT(*) FILTER (WHERE date_received >= $2) as emails_today, COUNT(*) FILTER (WHERE ai_analyzed_at >= $2) as ai_analyses_today FROM aggregated_emails WHERE user_id = $1 """, user_id, today ) # Task counts task_stats = await conn.fetchrow( """ SELECT COUNT(*) as total_tasks, COUNT(*) FILTER (WHERE status = 'pending') as pending_tasks, COUNT(*) FILTER (WHERE status != 'completed' AND deadline < NOW()) as overdue_tasks FROM inbox_tasks WHERE user_id = $1 """, user_id ) return { "total_accounts": len(accounts), "active_accounts": sum(1 for a in accounts if a['status'] == 'active'), "error_accounts": sum(1 for a in accounts if a['status'] == 'error'), "total_emails": email_stats['total_emails'] or 0, "unread_emails": email_stats['unread_emails'] or 0, "total_tasks": task_stats['total_tasks'] or 0, "pending_tasks": task_stats['pending_tasks'] or 0, "overdue_tasks": task_stats['overdue_tasks'] or 0, "emails_today": email_stats['emails_today'] or 0, "ai_analyses_today": email_stats['ai_analyses_today'] or 0, "per_account": [ { "id": a['id'], "email": a['email'], "display_name": a['display_name'], "status": a['status'], "email_count": a['email_count'], "unread_count": a['unread_count'], "last_sync": a['last_sync'].isoformat() if a['last_sync'] else None, } for a in accounts ], } except Exception as e: print(f"Failed to get mail stats: {e}") return {} async def log_mail_audit( user_id: str, action: str, entity_type: Optional[str] = None, entity_id: Optional[str] = None, details: Optional[Dict] = None, tenant_id: Optional[str] = None, ip_address: Optional[str] = None, user_agent: Optional[str] = None, ) -> bool: """Log a mail action for audit trail.""" pool = await get_pool() if pool is None: return False try: async with pool.acquire() as conn: await conn.execute( """ INSERT INTO mail_audit_log (id, user_id, tenant_id, action, entity_type, entity_id, details, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) """, str(uuid.uuid4()), user_id, tenant_id, action, entity_type, entity_id, json.dumps(details) if details else None, ip_address, user_agent ) return True except Exception as e: print(f"Failed to log mail audit: {e}") return False