docs: Create vTiger & Simply-CRM integration setup guide with credential requirements feat: Implement ticket system enhancements including relations, calendar events, templates, and AI suggestions refactor: Update ticket system migration to include audit logging and enhanced email metadata
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, execute_query_single
|
|
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_single(query, (ticket_number,))
|
|
|
|
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
|