release: v2.2.36 helpdesk sag routing
This commit is contained in:
parent
2d2c7aeb9b
commit
b0a51f1919
30
RELEASE_NOTES_v2.2.36.md
Normal file
30
RELEASE_NOTES_v2.2.36.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# BMC Hub v2.2.36 - Helpdesk SAG Routing
|
||||||
|
|
||||||
|
**Release Date:** 2. marts 2026
|
||||||
|
|
||||||
|
## ✨ New Features
|
||||||
|
|
||||||
|
### Helpdesk email → SAG automation
|
||||||
|
- Incoming emails from known customer domains now auto-create a new SAG when no `SAG-<id>` reference is present.
|
||||||
|
- Incoming emails with `SAG-<id>` in subject or threading headers now auto-update the related SAG.
|
||||||
|
- Emails from unknown domains remain in `/emails` for manual handling.
|
||||||
|
|
||||||
|
### Email threading support for routing
|
||||||
|
- Added migration `141_email_threading_headers.sql`.
|
||||||
|
- `email_messages` now stores `in_reply_to` and `email_references` to support robust SAG threading lookup.
|
||||||
|
|
||||||
|
### /emails quick customer creation improvements
|
||||||
|
- Quick create customer modal now includes `email_domain`.
|
||||||
|
- Customer create API now accepts and persists `email_domain`.
|
||||||
|
|
||||||
|
## 🔧 Technical Changes
|
||||||
|
|
||||||
|
- Updated `app/services/email_service.py` to parse and persist `In-Reply-To` and `References` from IMAP/EML uploads.
|
||||||
|
- Updated `app/services/email_workflow_service.py` with system-level helpdesk SAG routing logic.
|
||||||
|
- Updated `app/emails/backend/router.py` to include `customer_name` in email list responses.
|
||||||
|
- Updated `app/customers/backend/router.py` and `app/emails/frontend/emails.html` for `email_domain` support.
|
||||||
|
|
||||||
|
## 📋 Deployment Notes
|
||||||
|
|
||||||
|
- Run database migration 141 before processing new inbound emails for full header-based routing behavior.
|
||||||
|
- Existing workflow/rule behavior is preserved; new routing runs as a system workflow.
|
||||||
@ -28,6 +28,7 @@ class CustomerBase(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
cvr_number: Optional[str] = None
|
cvr_number: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
email_domain: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
city: Optional[str] = None
|
city: Optional[str] = None
|
||||||
@ -48,6 +49,7 @@ class CustomerUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
cvr_number: Optional[str] = None
|
cvr_number: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
|
email_domain: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
city: Optional[str] = None
|
city: Optional[str] = None
|
||||||
@ -495,14 +497,15 @@ async def create_customer(customer: CustomerCreate):
|
|||||||
try:
|
try:
|
||||||
customer_id = execute_insert(
|
customer_id = execute_insert(
|
||||||
"""INSERT INTO customers
|
"""INSERT INTO customers
|
||||||
(name, cvr_number, email, phone, address, city, postal_code,
|
(name, cvr_number, email, email_domain, phone, address, city, postal_code,
|
||||||
country, website, is_active, invoice_email, mobile_phone)
|
country, website, is_active, invoice_email, mobile_phone)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id""",
|
RETURNING id""",
|
||||||
(
|
(
|
||||||
customer.name,
|
customer.name,
|
||||||
customer.cvr_number,
|
customer.cvr_number,
|
||||||
customer.email,
|
customer.email,
|
||||||
|
customer.email_domain,
|
||||||
customer.phone,
|
customer.phone,
|
||||||
customer.address,
|
customer.address,
|
||||||
customer.city,
|
customer.city,
|
||||||
|
|||||||
@ -183,10 +183,11 @@ async def list_emails(
|
|||||||
em.body_text, em.body_html,
|
em.body_text, em.body_html,
|
||||||
er.name as rule_name,
|
er.name as rule_name,
|
||||||
v.name as supplier_name,
|
v.name as supplier_name,
|
||||||
NULL as customer_name
|
c.name as customer_name
|
||||||
FROM email_messages em
|
FROM email_messages em
|
||||||
LEFT JOIN email_rules er ON em.rule_id = er.id
|
LEFT JOIN email_rules er ON em.rule_id = er.id
|
||||||
LEFT JOIN vendors v ON em.supplier_id = v.id
|
LEFT JOIN vendors v ON em.supplier_id = v.id
|
||||||
|
LEFT JOIN customers c ON em.customer_id = c.id
|
||||||
WHERE {where_sql}
|
WHERE {where_sql}
|
||||||
ORDER BY em.received_date DESC
|
ORDER BY em.received_date DESC
|
||||||
LIMIT %s OFFSET %s
|
LIMIT %s OFFSET %s
|
||||||
|
|||||||
@ -2281,9 +2281,11 @@ async function linkToCustomer(emailId) {
|
|||||||
|
|
||||||
// ─── Quick Create Customer ────────────────────────────────────────────────
|
// ─── Quick Create Customer ────────────────────────────────────────────────
|
||||||
function quickCreateCustomer(emailId, senderName, senderEmail) {
|
function quickCreateCustomer(emailId, senderName, senderEmail) {
|
||||||
|
const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : '';
|
||||||
document.getElementById('qcEmailId').value = emailId;
|
document.getElementById('qcEmailId').value = emailId;
|
||||||
document.getElementById('qcCustomerName').value = senderName || '';
|
document.getElementById('qcCustomerName').value = senderName || '';
|
||||||
document.getElementById('qcCustomerEmail').value = senderEmail || '';
|
document.getElementById('qcCustomerEmail').value = senderEmail || '';
|
||||||
|
document.getElementById('qcCustomerDomain').value = senderDomain;
|
||||||
document.getElementById('qcCustomerPhone').value = '';
|
document.getElementById('qcCustomerPhone').value = '';
|
||||||
document.getElementById('qcCustomerStatus').textContent = '';
|
document.getElementById('qcCustomerStatus').textContent = '';
|
||||||
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
|
const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
|
||||||
@ -2294,6 +2296,7 @@ async function submitQuickCustomer() {
|
|||||||
const emailId = document.getElementById('qcEmailId').value;
|
const emailId = document.getElementById('qcEmailId').value;
|
||||||
const name = document.getElementById('qcCustomerName').value.trim();
|
const name = document.getElementById('qcCustomerName').value.trim();
|
||||||
const email = document.getElementById('qcCustomerEmail').value.trim();
|
const email = document.getElementById('qcCustomerEmail').value.trim();
|
||||||
|
const domain = document.getElementById('qcCustomerDomain').value.trim().toLowerCase();
|
||||||
const phone = document.getElementById('qcCustomerPhone').value.trim();
|
const phone = document.getElementById('qcCustomerPhone').value.trim();
|
||||||
const statusEl = document.getElementById('qcCustomerStatus');
|
const statusEl = document.getElementById('qcCustomerStatus');
|
||||||
|
|
||||||
@ -2305,7 +2308,12 @@ async function submitQuickCustomer() {
|
|||||||
const custResp = await fetch('/api/v1/customers', {
|
const custResp = await fetch('/api/v1/customers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ name, email: email || null, phone: phone || null })
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
email: email || null,
|
||||||
|
email_domain: domain || null,
|
||||||
|
phone: phone || null
|
||||||
|
})
|
||||||
});
|
});
|
||||||
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
|
||||||
const customer = await custResp.json();
|
const customer = await custResp.json();
|
||||||
@ -4320,6 +4328,10 @@ async function uploadEmailFiles(files) {
|
|||||||
<label class="form-label fw-semibold">Email</label>
|
<label class="form-label fw-semibold">Email</label>
|
||||||
<input type="email" class="form-control" id="qcCustomerEmail">
|
<input type="email" class="form-control" id="qcCustomerEmail">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Email-domæne</label>
|
||||||
|
<input type="text" class="form-control" id="qcCustomerDomain" placeholder="firma.dk">
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">Telefon</label>
|
<label class="form-label fw-semibold">Telefon</label>
|
||||||
<input type="text" class="form-control" id="qcCustomerPhone">
|
<input type="text" class="form-control" id="qcCustomerPhone">
|
||||||
|
|||||||
@ -276,6 +276,8 @@ class EmailService:
|
|||||||
|
|
||||||
# Get message ID
|
# Get message ID
|
||||||
message_id = msg.get('Message-ID', f"imap-{email_id}")
|
message_id = msg.get('Message-ID', f"imap-{email_id}")
|
||||||
|
in_reply_to = msg.get('In-Reply-To', '')
|
||||||
|
email_references = msg.get('References', '')
|
||||||
|
|
||||||
# Get date
|
# Get date
|
||||||
date_str = msg.get('Date', '')
|
date_str = msg.get('Date', '')
|
||||||
@ -349,6 +351,8 @@ class EmailService:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'message_id': message_id,
|
'message_id': message_id,
|
||||||
|
'in_reply_to': in_reply_to,
|
||||||
|
'email_references': email_references,
|
||||||
'subject': subject,
|
'subject': subject,
|
||||||
'sender_name': sender_name,
|
'sender_name': sender_name,
|
||||||
'sender_email': sender_email,
|
'sender_email': sender_email,
|
||||||
@ -393,6 +397,8 @@ class EmailService:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'message_id': msg.get('internetMessageId', msg.get('id', '')),
|
'message_id': msg.get('internetMessageId', msg.get('id', '')),
|
||||||
|
'in_reply_to': None,
|
||||||
|
'email_references': None,
|
||||||
'subject': msg.get('subject', ''),
|
'subject': msg.get('subject', ''),
|
||||||
'sender_name': sender_name,
|
'sender_name': sender_name,
|
||||||
'sender_email': sender_email,
|
'sender_email': sender_email,
|
||||||
@ -527,8 +533,9 @@ class EmailService:
|
|||||||
INSERT INTO email_messages
|
INSERT INTO email_messages
|
||||||
(message_id, subject, sender_email, sender_name, recipient_email, cc,
|
(message_id, subject, sender_email, sender_name, recipient_email, cc,
|
||||||
body_text, body_html, received_date, folder, has_attachments, attachment_count,
|
body_text, body_html, received_date, folder, has_attachments, attachment_count,
|
||||||
|
in_reply_to, email_references,
|
||||||
status, is_read)
|
status, is_read)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'new', false)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -544,7 +551,9 @@ class EmailService:
|
|||||||
email_data['received_date'],
|
email_data['received_date'],
|
||||||
email_data['folder'],
|
email_data['folder'],
|
||||||
email_data['has_attachments'],
|
email_data['has_attachments'],
|
||||||
email_data['attachment_count']
|
email_data['attachment_count'],
|
||||||
|
email_data.get('in_reply_to'),
|
||||||
|
email_data.get('email_references')
|
||||||
))
|
))
|
||||||
|
|
||||||
logger.info(f"✅ Saved email {email_id}: {email_data['subject'][:50]}...")
|
logger.info(f"✅ Saved email {email_id}: {email_data['subject'][:50]}...")
|
||||||
@ -761,6 +770,8 @@ class EmailService:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
|
"in_reply_to": msg.get("In-Reply-To", ""),
|
||||||
|
"email_references": msg.get("References", ""),
|
||||||
"subject": msg.get("Subject", "No Subject"),
|
"subject": msg.get("Subject", "No Subject"),
|
||||||
"sender_name": sender_name,
|
"sender_name": sender_name,
|
||||||
"sender_email": sender_email,
|
"sender_email": sender_email,
|
||||||
@ -826,6 +837,8 @@ class EmailService:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"message_id": message_id,
|
"message_id": message_id,
|
||||||
|
"in_reply_to": None,
|
||||||
|
"email_references": None,
|
||||||
"subject": msg.subject or "No Subject",
|
"subject": msg.subject or "No Subject",
|
||||||
"sender_name": msg.sender or "",
|
"sender_name": msg.sender or "",
|
||||||
"sender_email": msg.senderEmail or "",
|
"sender_email": msg.senderEmail or "",
|
||||||
@ -868,9 +881,10 @@ class EmailService:
|
|||||||
message_id, subject, sender_email, sender_name,
|
message_id, subject, sender_email, sender_name,
|
||||||
recipient_email, cc, body_text, body_html,
|
recipient_email, cc, body_text, body_html,
|
||||||
received_date, folder, has_attachments, attachment_count,
|
received_date, folder, has_attachments, attachment_count,
|
||||||
|
in_reply_to, email_references,
|
||||||
status, import_method, created_at
|
status, import_method, created_at
|
||||||
)
|
)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -887,6 +901,8 @@ class EmailService:
|
|||||||
email_data["folder"],
|
email_data["folder"],
|
||||||
email_data["has_attachments"],
|
email_data["has_attachments"],
|
||||||
len(email_data.get("attachments", [])),
|
len(email_data.get("attachments", [])),
|
||||||
|
email_data.get("in_reply_to"),
|
||||||
|
email_data.get("email_references"),
|
||||||
"new",
|
"new",
|
||||||
"manual_upload"
|
"manual_upload"
|
||||||
))
|
))
|
||||||
|
|||||||
@ -26,6 +26,17 @@ class EmailWorkflowService:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.enabled = settings.EMAIL_WORKFLOWS_ENABLED if hasattr(settings, 'EMAIL_WORKFLOWS_ENABLED') else True
|
self.enabled = settings.EMAIL_WORKFLOWS_ENABLED if hasattr(settings, 'EMAIL_WORKFLOWS_ENABLED') else True
|
||||||
|
|
||||||
|
HELPDESK_SKIP_CLASSIFICATIONS = {
|
||||||
|
'invoice',
|
||||||
|
'order_confirmation',
|
||||||
|
'freight_note',
|
||||||
|
'time_confirmation',
|
||||||
|
'newsletter',
|
||||||
|
'spam',
|
||||||
|
'bankruptcy',
|
||||||
|
'recording'
|
||||||
|
}
|
||||||
|
|
||||||
async def execute_workflows(self, email_data: Dict) -> Dict:
|
async def execute_workflows(self, email_data: Dict) -> Dict:
|
||||||
"""
|
"""
|
||||||
@ -69,6 +80,18 @@ class EmailWorkflowService:
|
|||||||
results['workflows_executed'] += 1
|
results['workflows_executed'] += 1
|
||||||
results['workflows_succeeded'] += 1
|
results['workflows_succeeded'] += 1
|
||||||
logger.info("✅ Bankruptcy system workflow executed successfully")
|
logger.info("✅ Bankruptcy system workflow executed successfully")
|
||||||
|
|
||||||
|
# Special System Workflow: Helpdesk SAG routing
|
||||||
|
# - If SAG-<id> is present in subject/header => update existing case
|
||||||
|
# - If no SAG id and sender domain matches customer => create new case
|
||||||
|
if classification not in self.HELPDESK_SKIP_CLASSIFICATIONS:
|
||||||
|
helpdesk_result = await self._handle_helpdesk_sag_routing(email_data)
|
||||||
|
if helpdesk_result:
|
||||||
|
results['details'].append(helpdesk_result)
|
||||||
|
if helpdesk_result.get('status') == 'completed':
|
||||||
|
results['workflows_executed'] += 1
|
||||||
|
results['workflows_succeeded'] += 1
|
||||||
|
logger.info("✅ Helpdesk SAG routing workflow executed")
|
||||||
|
|
||||||
# Find matching workflows
|
# Find matching workflows
|
||||||
workflows = await self._find_matching_workflows(email_data)
|
workflows = await self._find_matching_workflows(email_data)
|
||||||
@ -176,6 +199,188 @@ class EmailWorkflowService:
|
|||||||
'customer_name': first_match['name']
|
'customer_name': first_match['name']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _extract_sender_domain(self, email_data: Dict) -> Optional[str]:
|
||||||
|
sender_email = (email_data.get('sender_email') or '').strip().lower()
|
||||||
|
if '@' not in sender_email:
|
||||||
|
return None
|
||||||
|
domain = sender_email.split('@', 1)[1].strip()
|
||||||
|
if domain.startswith('www.'):
|
||||||
|
domain = domain[4:]
|
||||||
|
return domain or None
|
||||||
|
|
||||||
|
def _extract_sag_id(self, email_data: Dict) -> Optional[int]:
|
||||||
|
candidates = [
|
||||||
|
email_data.get('subject') or '',
|
||||||
|
email_data.get('in_reply_to') or '',
|
||||||
|
email_data.get('email_references') or ''
|
||||||
|
]
|
||||||
|
|
||||||
|
for value in candidates:
|
||||||
|
match = re.search(r'\bSAG-(\d+)\b', value, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return int(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
|
||||||
|
if not domain:
|
||||||
|
return None
|
||||||
|
|
||||||
|
domain = domain.lower().strip()
|
||||||
|
domain_alt = domain[4:] if domain.startswith('www.') else f"www.{domain}"
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT id, name
|
||||||
|
FROM customers
|
||||||
|
WHERE is_active = true
|
||||||
|
AND (
|
||||||
|
LOWER(TRIM(email_domain)) = %s
|
||||||
|
OR LOWER(TRIM(email_domain)) = %s
|
||||||
|
)
|
||||||
|
ORDER BY id ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
rows = execute_query(query, (domain, domain_alt))
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
def _link_email_to_sag(self, sag_id: int, email_id: int) -> None:
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_emails (sag_id, email_id)
|
||||||
|
SELECT %s, %s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sag_emails WHERE sag_id = %s AND email_id = %s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(sag_id, email_id, sag_id, email_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_helpdesk_comment(self, sag_id: int, email_data: Dict) -> None:
|
||||||
|
sender = email_data.get('sender_email') or 'ukendt'
|
||||||
|
subject = email_data.get('subject') or '(ingen emne)'
|
||||||
|
received = email_data.get('received_date')
|
||||||
|
received_str = received.isoformat() if hasattr(received, 'isoformat') else str(received or '')
|
||||||
|
body_text = (email_data.get('body_text') or '').strip()
|
||||||
|
|
||||||
|
comment = (
|
||||||
|
f"📧 Indgående email\n"
|
||||||
|
f"Fra: {sender}\n"
|
||||||
|
f"Emne: {subject}\n"
|
||||||
|
f"Modtaget: {received_str}\n\n"
|
||||||
|
f"{body_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(sag_id, 'Email Bot', comment, True)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_sag_from_email(self, email_data: Dict, customer_id: int) -> Dict[str, Any]:
|
||||||
|
sender = email_data.get('sender_email') or 'ukendt'
|
||||||
|
subject = (email_data.get('subject') or '').strip() or f"Email fra {sender}"
|
||||||
|
|
||||||
|
description = (
|
||||||
|
f"Auto-oprettet fra email\n"
|
||||||
|
f"Fra: {sender}\n"
|
||||||
|
f"Message-ID: {email_data.get('message_id') or ''}\n\n"
|
||||||
|
f"{(email_data.get('body_text') or '').strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO sag_sager (
|
||||||
|
titel, beskrivelse, template_key, status, customer_id, created_by_user_id
|
||||||
|
)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING id, titel, customer_id
|
||||||
|
""",
|
||||||
|
(subject, description, 'ticket', 'åben', customer_id, 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
raise ValueError('Failed to create SAG from email')
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
async def _handle_helpdesk_sag_routing(self, email_data: Dict) -> Optional[Dict[str, Any]]:
|
||||||
|
email_id = email_data.get('id')
|
||||||
|
if not email_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sag_id = self._extract_sag_id(email_data)
|
||||||
|
|
||||||
|
# 1) Existing SAG via subject/headers
|
||||||
|
if sag_id:
|
||||||
|
case_rows = execute_query(
|
||||||
|
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||||
|
(sag_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not case_rows:
|
||||||
|
logger.warning("⚠️ Email %s referenced SAG-%s but case was not found", email_id, sag_id)
|
||||||
|
return {'status': 'skipped', 'action': 'sag_id_not_found', 'sag_id': sag_id}
|
||||||
|
|
||||||
|
case = case_rows[0]
|
||||||
|
self._add_helpdesk_comment(sag_id, email_data)
|
||||||
|
self._link_email_to_sag(sag_id, email_id)
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE email_messages
|
||||||
|
SET linked_case_id = %s,
|
||||||
|
customer_id = COALESCE(customer_id, %s),
|
||||||
|
status = 'processed',
|
||||||
|
folder = 'Processed',
|
||||||
|
processed_at = CURRENT_TIMESTAMP,
|
||||||
|
auto_processed = true
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(sag_id, case.get('customer_id'), email_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'completed',
|
||||||
|
'action': 'updated_existing_sag',
|
||||||
|
'sag_id': sag_id,
|
||||||
|
'customer_id': case.get('customer_id')
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2) No SAG id -> create only if sender domain belongs to known customer
|
||||||
|
sender_domain = self._extract_sender_domain(email_data)
|
||||||
|
customer = self._find_customer_by_domain(sender_domain) if sender_domain else None
|
||||||
|
|
||||||
|
if not customer:
|
||||||
|
logger.info("⏭️ Email %s has no known customer domain (%s) - kept in /emails", email_id, sender_domain)
|
||||||
|
return {'status': 'skipped', 'action': 'unknown_customer_domain', 'domain': sender_domain}
|
||||||
|
|
||||||
|
case = self._create_sag_from_email(email_data, customer['id'])
|
||||||
|
self._add_helpdesk_comment(case['id'], email_data)
|
||||||
|
self._link_email_to_sag(case['id'], email_id)
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"""
|
||||||
|
UPDATE email_messages
|
||||||
|
SET linked_case_id = %s,
|
||||||
|
customer_id = %s,
|
||||||
|
status = 'processed',
|
||||||
|
folder = 'Processed',
|
||||||
|
processed_at = CURRENT_TIMESTAMP,
|
||||||
|
auto_processed = true
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(case['id'], customer['id'], email_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✅ Created SAG-%s from email %s for customer %s", case['id'], email_id, customer['id'])
|
||||||
|
return {
|
||||||
|
'status': 'completed',
|
||||||
|
'action': 'created_new_sag',
|
||||||
|
'sag_id': case['id'],
|
||||||
|
'customer_id': customer['id'],
|
||||||
|
'domain': sender_domain
|
||||||
|
}
|
||||||
|
|
||||||
async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]:
|
async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]:
|
||||||
"""Find all workflows that match this email"""
|
"""Find all workflows that match this email"""
|
||||||
classification = email_data.get('classification')
|
classification = email_data.get('classification')
|
||||||
@ -357,6 +562,7 @@ class EmailWorkflowService:
|
|||||||
handler_map = {
|
handler_map = {
|
||||||
'create_ticket': self._action_create_ticket_system,
|
'create_ticket': self._action_create_ticket_system,
|
||||||
'link_email_to_ticket': self._action_link_email_to_ticket,
|
'link_email_to_ticket': self._action_link_email_to_ticket,
|
||||||
|
'route_helpdesk_sag': self._handle_helpdesk_sag_routing,
|
||||||
'create_time_entry': self._action_create_time_entry,
|
'create_time_entry': self._action_create_time_entry,
|
||||||
'link_to_vendor': self._action_link_to_vendor,
|
'link_to_vendor': self._action_link_to_vendor,
|
||||||
'link_to_customer': self._action_link_to_customer,
|
'link_to_customer': self._action_link_to_customer,
|
||||||
@ -469,8 +675,8 @@ class EmailWorkflowService:
|
|||||||
'body': email_data.get('body_text', ''),
|
'body': email_data.get('body_text', ''),
|
||||||
'html_body': email_data.get('body_html'),
|
'html_body': email_data.get('body_html'),
|
||||||
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
|
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
|
||||||
'in_reply_to': None, # TODO: Extract from email headers
|
'in_reply_to': email_data.get('in_reply_to'),
|
||||||
'references': None # TODO: Extract from email headers
|
'references': email_data.get('email_references')
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get params from workflow
|
# Get params from workflow
|
||||||
@ -516,6 +722,8 @@ class EmailWorkflowService:
|
|||||||
'body': email_data.get('body_text', ''),
|
'body': email_data.get('body_text', ''),
|
||||||
'html_body': email_data.get('body_html'),
|
'html_body': email_data.get('body_html'),
|
||||||
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
|
'received_at': email_data.get('received_date').isoformat() if email_data.get('received_date') else None,
|
||||||
|
'in_reply_to': email_data.get('in_reply_to'),
|
||||||
|
'references': email_data.get('email_references')
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"🔗 Linking email to ticket {ticket_number}")
|
logger.info(f"🔗 Linking email to ticket {ticket_number}")
|
||||||
@ -594,13 +802,31 @@ class EmailWorkflowService:
|
|||||||
return {'action': 'link_to_vendor', 'matched': False, 'reason': 'Vendor not found'}
|
return {'action': 'link_to_vendor', 'matched': False, 'reason': 'Vendor not found'}
|
||||||
|
|
||||||
async def _action_link_to_customer(self, params: Dict, email_data: Dict) -> Dict:
|
async def _action_link_to_customer(self, params: Dict, email_data: Dict) -> Dict:
|
||||||
"""Link email to customer"""
|
"""Link email to customer by sender domain and persist on email_messages"""
|
||||||
logger.info(f"🔗 Would link to customer")
|
sender_domain = self._extract_sender_domain(email_data)
|
||||||
|
if not sender_domain:
|
||||||
# TODO: Implement customer matching logic
|
return {'action': 'link_to_customer', 'matched': False, 'reason': 'No sender domain'}
|
||||||
|
|
||||||
|
customer = self._find_customer_by_domain(sender_domain)
|
||||||
|
if not customer:
|
||||||
|
return {
|
||||||
|
'action': 'link_to_customer',
|
||||||
|
'matched': False,
|
||||||
|
'reason': 'Customer not found for domain',
|
||||||
|
'domain': sender_domain
|
||||||
|
}
|
||||||
|
|
||||||
|
execute_update(
|
||||||
|
"UPDATE email_messages SET customer_id = %s WHERE id = %s",
|
||||||
|
(customer['id'], email_data['id'])
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'action': 'link_to_customer',
|
'action': 'link_to_customer',
|
||||||
'note': 'Customer linking not yet implemented'
|
'matched': True,
|
||||||
|
'customer_id': customer['id'],
|
||||||
|
'customer_name': customer['name'],
|
||||||
|
'domain': sender_domain
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _action_extract_invoice_data(self, params: Dict, email_data: Dict) -> Dict:
|
async def _action_extract_invoice_data(self, params: Dict, email_data: Dict) -> Dict:
|
||||||
|
|||||||
12
migrations/141_email_threading_headers.sql
Normal file
12
migrations/141_email_threading_headers.sql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Migration 141: Store email threading headers for helpdesk case routing
|
||||||
|
|
||||||
|
ALTER TABLE email_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS in_reply_to VARCHAR(500),
|
||||||
|
ADD COLUMN IF NOT EXISTS email_references TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_messages_in_reply_to
|
||||||
|
ON email_messages(in_reply_to)
|
||||||
|
WHERE in_reply_to IS NOT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN email_messages.in_reply_to IS 'Raw In-Reply-To header used for SAG-<id> threading lookup';
|
||||||
|
COMMENT ON COLUMN email_messages.email_references IS 'Raw References header used for SAG-<id> threading lookup';
|
||||||
Loading…
Reference in New Issue
Block a user