Compare commits

...

2 Commits

Author SHA1 Message Date
Christian
b80f91fae1 release: v2.2.37
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 10:52:53 +01:00
Christian
81cc3a4a9e fix: enhance email threading and extraction logic in sag email handling 2026-03-03 10:42:16 +01:00
4 changed files with 101 additions and 18 deletions

View File

@ -1 +1 @@
2.2.36
2.2.37

View File

@ -1893,11 +1893,25 @@ async def add_sag_email_link(sag_id: int, payload: dict):
async def get_sag_emails(sag_id: int):
"""Get emails linked to a case."""
query = """
SELECT e.*
FROM email_messages e
JOIN sag_emails se ON e.id = se.email_id
WHERE se.sag_id = %s
ORDER BY e.received_date DESC
WITH linked_emails AS (
SELECT
e.*,
COALESCE(
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 []
@ -1969,6 +1983,8 @@ async def upload_sag_email(sag_id: int, file: UploadFile = File(...)):
email_data = {
'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')),
'sender_email': _decode_header_str(msg.get('From', '')),
'sender_name': _decode_header_str(msg.get('From', '')),

View File

@ -5837,20 +5837,40 @@
return;
}
setModuleContentState('emails', true);
container.innerHTML = emails.map(e => `
<div class="list-group-item">
<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>
const threadMap = new Map();
emails.forEach(e => {
const key = e.thread_key || `email-${e.id}`;
if(!threadMap.has(key)) threadMap.set(key, []);
threadMap.get(key).push(e);
});
const threads = Array.from(threadMap.values());
container.innerHTML = threads.map((threadEmails, threadIndex) => {
const labelEmail = threadEmails[0];
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>
${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>
`).join('');
`;
}).join('');
}
async function unlinkEmail(emailId) {

View File

@ -221,6 +221,49 @@ class EmailWorkflowService:
return int(match.group(1))
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]]:
if not domain:
return None
@ -309,6 +352,10 @@ class EmailWorkflowService:
return None
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
if sag_id: