- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs. - Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups. - Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
473 lines
15 KiB
Python
473 lines
15 KiB
Python
"""
|
|
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
|