bmc_hub/app/ticket/backend/email_integration.py

473 lines
15 KiB
Python
Raw Normal View History

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