fix: enhance email threading and extraction logic in sag email handling
This commit is contained in:
parent
b0a51f1919
commit
81cc3a4a9e
@ -1893,11 +1893,25 @@ async def add_sag_email_link(sag_id: int, payload: dict):
|
|||||||
async def get_sag_emails(sag_id: int):
|
async def get_sag_emails(sag_id: int):
|
||||||
"""Get emails linked to a case."""
|
"""Get emails linked to a case."""
|
||||||
query = """
|
query = """
|
||||||
SELECT e.*
|
WITH linked_emails AS (
|
||||||
FROM email_messages e
|
SELECT
|
||||||
JOIN sag_emails se ON e.id = se.email_id
|
e.*,
|
||||||
WHERE se.sag_id = %s
|
COALESCE(
|
||||||
ORDER BY e.received_date DESC
|
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.in_reply_to, '')), '[<>\\s]', '', 'g'), ''),
|
||||||
|
NULLIF(REGEXP_REPLACE((REGEXP_SPLIT_TO_ARRAY(COALESCE(e.email_references, ''), E'[\\s,]+'))[1], '[<>\\s]', '', 'g'), ''),
|
||||||
|
NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.message_id, '')), '[<>\\s]', '', 'g'), ''),
|
||||||
|
CONCAT('email-', e.id::text)
|
||||||
|
) AS thread_key
|
||||||
|
FROM email_messages e
|
||||||
|
JOIN sag_emails se ON e.id = se.email_id
|
||||||
|
WHERE se.sag_id = %s
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
linked_emails.*,
|
||||||
|
COUNT(*) OVER (PARTITION BY linked_emails.thread_key) AS thread_message_count,
|
||||||
|
MAX(linked_emails.received_date) OVER (PARTITION BY linked_emails.thread_key) AS thread_last_received_date
|
||||||
|
FROM linked_emails
|
||||||
|
ORDER BY thread_last_received_date DESC NULLS LAST, received_date DESC
|
||||||
"""
|
"""
|
||||||
return execute_query(query, (sag_id,)) or []
|
return execute_query(query, (sag_id,)) or []
|
||||||
|
|
||||||
@ -1969,6 +1983,8 @@ async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
|
|||||||
|
|
||||||
email_data = {
|
email_data = {
|
||||||
'message_id': msg.get('Message-ID', f"eml-{temp_id}"),
|
'message_id': msg.get('Message-ID', f"eml-{temp_id}"),
|
||||||
|
'in_reply_to': _decode_header_str(msg.get('In-Reply-To', '')),
|
||||||
|
'email_references': _decode_header_str(msg.get('References', '')),
|
||||||
'subject': _decode_header_str(msg.get('Subject', 'No Subject')),
|
'subject': _decode_header_str(msg.get('Subject', 'No Subject')),
|
||||||
'sender_email': _decode_header_str(msg.get('From', '')),
|
'sender_email': _decode_header_str(msg.get('From', '')),
|
||||||
'sender_name': _decode_header_str(msg.get('From', '')),
|
'sender_name': _decode_header_str(msg.get('From', '')),
|
||||||
|
|||||||
@ -5837,20 +5837,40 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setModuleContentState('emails', true);
|
setModuleContentState('emails', true);
|
||||||
container.innerHTML = emails.map(e => `
|
const threadMap = new Map();
|
||||||
<div class="list-group-item">
|
emails.forEach(e => {
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
const key = e.thread_key || `email-${e.id}`;
|
||||||
<div class="text-truncate">
|
if(!threadMap.has(key)) threadMap.set(key, []);
|
||||||
<i class="bi bi-envelope text-primary me-1"></i>
|
threadMap.get(key).push(e);
|
||||||
<strong>${e.subject || '(Ingen emne)'}</strong>
|
});
|
||||||
<div class="small text-muted text-truncate">${e.sender_email}</div>
|
|
||||||
</div>
|
const threads = Array.from(threadMap.values());
|
||||||
<button class="btn btn-sm btn-link text-danger p-0 ms-2" onclick="unlinkEmail(${e.id})">
|
container.innerHTML = threads.map((threadEmails, threadIndex) => {
|
||||||
<i class="bi bi-link-45deg" style="text-decoration: line-through;"></i>
|
const labelEmail = threadEmails[0];
|
||||||
</button>
|
const messageCount = labelEmail.thread_message_count || threadEmails.length;
|
||||||
|
return `
|
||||||
|
<div class="list-group-item p-0">
|
||||||
|
<div class="px-3 py-2 border-bottom bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<span class="small fw-semibold text-secondary">Tråd ${threadIndex + 1}</span>
|
||||||
|
<span class="badge bg-primary-subtle text-primary-emphasis">${messageCount} beskeder</span>
|
||||||
</div>
|
</div>
|
||||||
|
${threadEmails.map(e => `
|
||||||
|
<div class="px-3 py-2 border-bottom">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="text-truncate">
|
||||||
|
<i class="bi bi-envelope text-primary me-1"></i>
|
||||||
|
<strong>${e.subject || '(Ingen emne)'}</strong>
|
||||||
|
<div class="small text-muted text-truncate">${e.sender_email || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-link text-danger p-0 ms-2" onclick="unlinkEmail(${e.id})">
|
||||||
|
<i class="bi bi-link-45deg" style="text-decoration: line-through;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unlinkEmail(emailId) {
|
async function unlinkEmail(emailId) {
|
||||||
|
|||||||
@ -221,6 +221,49 @@ class EmailWorkflowService:
|
|||||||
return int(match.group(1))
|
return int(match.group(1))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _normalize_message_id(self, value: Optional[str]) -> Optional[str]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = re.sub(r'[<>\s]', '', str(value)).lower().strip()
|
||||||
|
return normalized or None
|
||||||
|
|
||||||
|
def _extract_thread_message_ids(self, email_data: Dict) -> List[str]:
|
||||||
|
tokens: List[str] = []
|
||||||
|
|
||||||
|
in_reply_to = self._normalize_message_id(email_data.get('in_reply_to'))
|
||||||
|
if in_reply_to:
|
||||||
|
tokens.append(in_reply_to)
|
||||||
|
|
||||||
|
raw_references = (email_data.get('email_references') or '').strip()
|
||||||
|
if raw_references:
|
||||||
|
for ref in re.split(r'[\s,]+', raw_references):
|
||||||
|
normalized_ref = self._normalize_message_id(ref)
|
||||||
|
if normalized_ref:
|
||||||
|
tokens.append(normalized_ref)
|
||||||
|
|
||||||
|
# De-duplicate while preserving order
|
||||||
|
return list(dict.fromkeys(tokens))
|
||||||
|
|
||||||
|
def _find_sag_id_from_thread_headers(self, email_data: Dict) -> Optional[int]:
|
||||||
|
thread_message_ids = self._extract_thread_message_ids(email_data)
|
||||||
|
if not thread_message_ids:
|
||||||
|
return None
|
||||||
|
|
||||||
|
placeholders = ','.join(['%s'] * len(thread_message_ids))
|
||||||
|
rows = execute_query(
|
||||||
|
f"""
|
||||||
|
SELECT se.sag_id
|
||||||
|
FROM sag_emails se
|
||||||
|
JOIN email_messages em ON em.id = se.email_id
|
||||||
|
WHERE em.deleted_at IS NULL
|
||||||
|
AND LOWER(REGEXP_REPLACE(COALESCE(em.message_id, ''), '[<>\\s]', '', 'g')) IN ({placeholders})
|
||||||
|
ORDER BY se.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
tuple(thread_message_ids)
|
||||||
|
)
|
||||||
|
return rows[0]['sag_id'] if rows else None
|
||||||
|
|
||||||
def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
|
def _find_customer_by_domain(self, domain: str) -> Optional[Dict[str, Any]]:
|
||||||
if not domain:
|
if not domain:
|
||||||
return None
|
return None
|
||||||
@ -309,6 +352,10 @@ class EmailWorkflowService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
sag_id = self._extract_sag_id(email_data)
|
sag_id = self._extract_sag_id(email_data)
|
||||||
|
if not sag_id:
|
||||||
|
sag_id = self._find_sag_id_from_thread_headers(email_data)
|
||||||
|
if sag_id:
|
||||||
|
logger.info("🔗 Matched email %s to SAG-%s via thread headers", email_id, sag_id)
|
||||||
|
|
||||||
# 1) Existing SAG via subject/headers
|
# 1) Existing SAG via subject/headers
|
||||||
if sag_id:
|
if sag_id:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user