diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py index 5559324..125e740 100644 --- a/app/modules/sag/backend/router.py +++ b/app/modules/sag/backend/router.py @@ -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', '')), diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html index 44a4afe..7fdf89c 100644 --- a/app/modules/sag/templates/detail.html +++ b/app/modules/sag/templates/detail.html @@ -5837,20 +5837,40 @@ return; } setModuleContentState('emails', true); - container.innerHTML = emails.map(e => ` -
-
-
- - ${e.subject || '(Ingen emne)'} -
${e.sender_email}
-
- + 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 ` +
+
+ Tråd ${threadIndex + 1} + ${messageCount} beskeder
+ ${threadEmails.map(e => ` +
+
+
+ + ${e.subject || '(Ingen emne)'} +
${e.sender_email || '-'}
+
+ +
+
+ `).join('')}
- `).join(''); + `; + }).join(''); } async function unlinkEmail(emailId) { diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py index 1342750..023beb2 100644 --- a/app/services/email_workflow_service.py +++ b/app/services/email_workflow_service.py @@ -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: