""" Email-to-Ticket Integration for BMC Hub Ticket System Implements Option B: Message-ID Threading - Parse email message_id from workflow context - Create tickets with email metadata - Link emails to existing tickets using In-Reply-To header - Extract ticket tags from email body - Store email threading info in tticket_email_log """ import logging import re from typing import Dict, Any, Optional, List from datetime import datetime from app.core.database import execute_query, execute_insert from app.ticket.backend.ticket_service import TicketService from app.ticket.backend.models import TTicketCreate, TicketPriority from psycopg2.extras import Json logger = logging.getLogger(__name__) class EmailTicketIntegration: """Handles email-to-ticket conversion and threading""" # Regex patterns for ticket detection TICKET_NUMBER_PATTERN = r'TKT-\d{8}-\d{3}' TAG_PATTERN = r'#(\w+)' @staticmethod async def create_ticket_from_email( email_data: Dict[str, Any], customer_id: Optional[int] = None, assigned_to_user_id: Optional[int] = None ) -> Dict[str, Any]: """ Create ticket from email workflow action Args: email_data: Dict with keys: - message_id: Email Message-ID header - subject: Email subject - from_address: Sender email - body: Email body text - html_body: Email HTML body (optional) - received_at: Email timestamp (ISO format) - in_reply_to: In-Reply-To header (optional) - references: References header (optional) customer_id: BMC customer ID (if known) assigned_to_user_id: User to assign ticket to Returns: Dict with ticket_id and ticket_number """ try: logger.info(f"🎫 Creating ticket from email: {email_data.get('message_id')}") # Extract tags from email body tags = EmailTicketIntegration._extract_tags(email_data.get('body', '')) # Determine priority from subject/tags priority = EmailTicketIntegration._determine_priority( email_data.get('subject', ''), tags ) # Create ticket ticket_create = TTicketCreate( subject=email_data.get('subject', 'Email without subject'), description=EmailTicketIntegration._format_description(email_data), customer_id=customer_id, assigned_to_user_id=assigned_to_user_id, priority=priority, tags=tags, custom_fields={ 'email_from': email_data.get('from_address'), 'email_message_id': email_data.get('message_id'), 'created_from_email': True } ) # Create via service (handles auto-number generation) ticket = await TicketService.create_ticket(ticket_create, created_by_user_id=None) # Log email-ticket linkage await EmailTicketIntegration._log_email_linkage( ticket_id=ticket['id'], email_data=email_data ) logger.info(f"✅ Created ticket {ticket['ticket_number']} from email {email_data.get('message_id')}") return { 'ticket_id': ticket['id'], 'ticket_number': ticket['ticket_number'], 'created': True } except Exception as e: logger.error(f"❌ Failed to create ticket from email: {e}") raise @staticmethod async def link_email_to_ticket( ticket_number: str, email_data: Dict[str, Any] ) -> Dict[str, Any]: """ Link email to existing ticket (reply threading) Args: ticket_number: TKT-YYYYMMDD-XXX format email_data: Same format as create_ticket_from_email Returns: Dict with ticket_id and linked=True """ try: logger.info(f"🔗 Linking email to ticket {ticket_number}") # Find ticket by ticket_number query = "SELECT id FROM tticket_tickets WHERE ticket_number = %s" result = execute_query(query, (ticket_number,), fetchone=True) if not result: logger.warning(f"⚠️ Ticket {ticket_number} not found - creating new ticket instead") return await EmailTicketIntegration.create_ticket_from_email(email_data) ticket_id = result['id'] # Log email linkage await EmailTicketIntegration._log_email_linkage( ticket_id=ticket_id, email_data=email_data, is_reply=True ) # Add comment with email content from app.ticket.backend.models import TTicketCommentCreate comment_text = f"📧 Email from {email_data.get('from_address')}:\n\n{email_data.get('body', '')}" await TicketService.add_comment( ticket_id=ticket_id, comment=TTicketCommentCreate( comment_text=comment_text, internal_note=False ), user_id=None ) logger.info(f"✅ Linked email to ticket {ticket_number} (ID: {ticket_id})") return { 'ticket_id': ticket_id, 'ticket_number': ticket_number, 'linked': True } except Exception as e: logger.error(f"❌ Failed to link email to ticket: {e}") raise @staticmethod async def process_email_for_ticket( email_data: Dict[str, Any], customer_id: Optional[int] = None, assigned_to_user_id: Optional[int] = None ) -> Dict[str, Any]: """ Smart processor: Creates new ticket OR links to existing based on threading Args: email_data: Email metadata dict customer_id: Customer ID if known assigned_to_user_id: User to assign new tickets to Returns: Dict with ticket_id, ticket_number, created/linked status """ try: # Check if email is reply to existing ticket ticket_number = EmailTicketIntegration._find_ticket_in_thread(email_data) if ticket_number: # Reply to existing ticket return await EmailTicketIntegration.link_email_to_ticket( ticket_number=ticket_number, email_data=email_data ) else: # New ticket return await EmailTicketIntegration.create_ticket_from_email( email_data=email_data, customer_id=customer_id, assigned_to_user_id=assigned_to_user_id ) except Exception as e: logger.error(f"❌ Failed to process email for ticket: {e}") raise @staticmethod def _find_ticket_in_thread(email_data: Dict[str, Any]) -> Optional[str]: """ Find ticket number in email thread (In-Reply-To, References, Subject) Returns: Ticket number (TKT-YYYYMMDD-XXX) or None """ # Check In-Reply-To header first in_reply_to = email_data.get('in_reply_to', '') if in_reply_to: match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, in_reply_to) if match: return match.group(0) # Check References header references = email_data.get('references', '') if references: match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, references) if match: return match.group(0) # Check subject line for [TKT-YYYYMMDD-XXX] or Re: TKT-YYYYMMDD-XXX subject = email_data.get('subject', '') match = re.search(EmailTicketIntegration.TICKET_NUMBER_PATTERN, subject) if match: return match.group(0) return None @staticmethod def _extract_tags(body: str) -> List[str]: """ Extract #hashtags from email body Returns: List of lowercase tags without # prefix """ if not body: return [] tags = re.findall(EmailTicketIntegration.TAG_PATTERN, body) return [tag.lower() for tag in tags if len(tag) > 2][:10] # Max 10 tags @staticmethod def _determine_priority(subject: str, tags: List[str]) -> TicketPriority: """ Determine ticket priority from subject/tags Returns: TicketPriority enum value """ subject_lower = subject.lower() all_text = f"{subject_lower} {' '.join(tags)}" # Critical keywords if any(word in all_text for word in ['kritisk', 'critical', 'down', 'nede', 'urgent', 'akut']): return TicketPriority.critical # High priority keywords if any(word in all_text for word in ['høj', 'high', 'vigtig', 'important', 'haster']): return TicketPriority.high # Low priority keywords if any(word in all_text for word in ['lav', 'low', 'spørgsmål', 'question', 'info']): return TicketPriority.low return TicketPriority.normal @staticmethod def _format_description(email_data: Dict[str, Any]) -> str: """ Format email body as ticket description Returns: Formatted description text """ body = email_data.get('body', '').strip() from_address = email_data.get('from_address', 'unknown') received_at = email_data.get('received_at', '') description = f"📧 Email fra: {from_address}\n" description += f"📅 Modtaget: {received_at}\n" description += f"{'='*60}\n\n" description += body return description @staticmethod async def _log_email_linkage( ticket_id: int, email_data: Dict[str, Any], is_reply: bool = False ) -> None: """ Store email-ticket linkage in tticket_email_log Args: ticket_id: Ticket ID email_data: Email metadata is_reply: True if email is reply to existing ticket """ query = """ INSERT INTO tticket_email_log ( ticket_id, email_message_id, email_subject, email_from, email_received_at, is_reply, thread_data ) VALUES (%s, %s, %s, %s, %s, %s, %s) """ # Parse received_at timestamp received_at = email_data.get('received_at') if isinstance(received_at, str): try: received_at = datetime.fromisoformat(received_at.replace('Z', '+00:00')) except: received_at = None thread_data = { 'in_reply_to': email_data.get('in_reply_to'), 'references': email_data.get('references'), 'message_id': email_data.get('message_id') } execute_insert( query, ( ticket_id, email_data.get('message_id'), email_data.get('subject'), email_data.get('from_address'), received_at, is_reply, Json(thread_data) ) ) logger.info(f"📝 Logged email linkage for ticket {ticket_id}") @staticmethod async def get_ticket_email_thread(ticket_id: int) -> List[Dict[str, Any]]: """ Get all emails linked to a ticket (chronological order) Args: ticket_id: Ticket ID Returns: List of email log entries """ query = """ SELECT id, email_message_id, email_subject, email_from, email_received_at, is_reply, thread_data, created_at FROM tticket_email_log WHERE ticket_id = %s ORDER BY email_received_at ASC """ return execute_query(query, (ticket_id,), fetchall=True) @staticmethod async def find_tickets_by_email_address(email_address: str) -> List[Dict[str, Any]]: """ Find all tickets associated with an email address Args: email_address: Email address to search for Returns: List of ticket info dicts """ query = """ SELECT DISTINCT t.id, t.ticket_number, t.subject, t.status, t.created_at FROM tticket_tickets t INNER JOIN tticket_email_log e ON e.ticket_id = t.id WHERE e.email_from = %s ORDER BY t.created_at DESC """ return execute_query(query, (email_address,), fetchall=True) # Workflow action functions (called from email workflow engine) async def workflow_action_create_ticket( context: Dict[str, Any], action_params: Dict[str, Any] ) -> Dict[str, Any]: """ Workflow action: Create ticket from email Usage in workflow: { "action": "create_ticket", "params": { "customer_id": 123, # Optional "assigned_to_user_id": 5, # Optional "priority": "high" # Optional override } } Args: context: Email workflow context with email_data action_params: Action parameters from workflow definition Returns: Result dict with ticket info """ email_data = context.get('email_data', {}) result = await EmailTicketIntegration.process_email_for_ticket( email_data=email_data, customer_id=action_params.get('customer_id'), assigned_to_user_id=action_params.get('assigned_to_user_id') ) return result async def workflow_action_link_email_to_ticket( context: Dict[str, Any], action_params: Dict[str, Any] ) -> Dict[str, Any]: """ Workflow action: Link email to existing ticket Usage in workflow: { "action": "link_email_to_ticket", "params": { "ticket_number": "TKT-20251215-001" } } Args: context: Email workflow context action_params: Must contain ticket_number Returns: Result dict with link status """ email_data = context.get('email_data', {}) ticket_number = action_params.get('ticket_number') if not ticket_number: raise ValueError("ticket_number required in action params") result = await EmailTicketIntegration.link_email_to_ticket( ticket_number=ticket_number, email_data=email_data ) return result