diff --git a/RELEASE_NOTES_v2.2.36.md b/RELEASE_NOTES_v2.2.36.md new file mode 100644 index 0000000..9f54faf --- /dev/null +++ b/RELEASE_NOTES_v2.2.36.md @@ -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-` reference is present. +- Incoming emails with `SAG-` 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. diff --git a/VERSION b/VERSION index cd39aac..8641762 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.26 +2.2.36 diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index 0377a9d..4514570 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -28,6 +28,7 @@ class CustomerBase(BaseModel): name: str cvr_number: Optional[str] = None email: Optional[str] = None + email_domain: Optional[str] = None phone: Optional[str] = None address: Optional[str] = None city: Optional[str] = None @@ -48,6 +49,7 @@ class CustomerUpdate(BaseModel): name: Optional[str] = None cvr_number: Optional[str] = None email: Optional[str] = None + email_domain: Optional[str] = None phone: Optional[str] = None address: Optional[str] = None city: Optional[str] = None @@ -495,14 +497,15 @@ async def create_customer(customer: CustomerCreate): try: customer_id = execute_insert( """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) - 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""", ( customer.name, customer.cvr_number, customer.email, + customer.email_domain, customer.phone, customer.address, customer.city, diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py index 2e3cf79..5cc7e44 100644 --- a/app/emails/backend/router.py +++ b/app/emails/backend/router.py @@ -183,10 +183,11 @@ async def list_emails( em.body_text, em.body_html, er.name as rule_name, v.name as supplier_name, - NULL as customer_name + c.name as customer_name FROM email_messages em LEFT JOIN email_rules er ON em.rule_id = er.id LEFT JOIN vendors v ON em.supplier_id = v.id + LEFT JOIN customers c ON em.customer_id = c.id WHERE {where_sql} ORDER BY em.received_date DESC LIMIT %s OFFSET %s diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html index 7bbb773..a44ca15 100644 --- a/app/emails/frontend/emails.html +++ b/app/emails/frontend/emails.html @@ -2281,9 +2281,11 @@ async function linkToCustomer(emailId) { // ─── Quick Create Customer ──────────────────────────────────────────────── function quickCreateCustomer(emailId, senderName, senderEmail) { + const senderDomain = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1].toLowerCase() : ''; document.getElementById('qcEmailId').value = emailId; document.getElementById('qcCustomerName').value = senderName || ''; document.getElementById('qcCustomerEmail').value = senderEmail || ''; + document.getElementById('qcCustomerDomain').value = senderDomain; document.getElementById('qcCustomerPhone').value = ''; document.getElementById('qcCustomerStatus').textContent = ''; const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal')); @@ -2294,6 +2296,7 @@ async function submitQuickCustomer() { const emailId = document.getElementById('qcEmailId').value; const name = document.getElementById('qcCustomerName').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 statusEl = document.getElementById('qcCustomerStatus'); @@ -2305,7 +2308,12 @@ async function submitQuickCustomer() { const custResp = await fetch('/api/v1/customers', { method: 'POST', 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'); const customer = await custResp.json(); @@ -4320,6 +4328,10 @@ async function uploadEmailFiles(files) { +
+ + +
diff --git a/app/services/email_service.py b/app/services/email_service.py index 5dc8626..06f6cad 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -276,6 +276,8 @@ class EmailService: # Get message 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 date_str = msg.get('Date', '') @@ -349,6 +351,8 @@ class EmailService: return { 'message_id': message_id, + 'in_reply_to': in_reply_to, + 'email_references': email_references, 'subject': subject, 'sender_name': sender_name, 'sender_email': sender_email, @@ -393,6 +397,8 @@ class EmailService: return { 'message_id': msg.get('internetMessageId', msg.get('id', '')), + 'in_reply_to': None, + 'email_references': None, 'subject': msg.get('subject', ''), 'sender_name': sender_name, 'sender_email': sender_email, @@ -527,8 +533,9 @@ class EmailService: INSERT INTO email_messages (message_id, subject, sender_email, sender_name, recipient_email, cc, body_text, body_html, received_date, folder, has_attachments, attachment_count, + in_reply_to, email_references, 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 """ @@ -544,7 +551,9 @@ class EmailService: email_data['received_date'], email_data['folder'], 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]}...") @@ -761,6 +770,8 @@ class EmailService: return { "message_id": message_id, + "in_reply_to": msg.get("In-Reply-To", ""), + "email_references": msg.get("References", ""), "subject": msg.get("Subject", "No Subject"), "sender_name": sender_name, "sender_email": sender_email, @@ -826,6 +837,8 @@ class EmailService: return { "message_id": message_id, + "in_reply_to": None, + "email_references": None, "subject": msg.subject or "No Subject", "sender_name": msg.sender or "", "sender_email": msg.senderEmail or "", @@ -868,9 +881,10 @@ class EmailService: message_id, subject, sender_email, sender_name, recipient_email, cc, body_text, body_html, received_date, folder, has_attachments, attachment_count, + in_reply_to, email_references, 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 """ @@ -887,6 +901,8 @@ class EmailService: email_data["folder"], email_data["has_attachments"], len(email_data.get("attachments", [])), + email_data.get("in_reply_to"), + email_data.get("email_references"), "new", "manual_upload" )) diff --git a/app/services/email_workflow_service.py b/app/services/email_workflow_service.py index 8c28a4e..1342750 100644 --- a/app/services/email_workflow_service.py +++ b/app/services/email_workflow_service.py @@ -26,6 +26,17 @@ class EmailWorkflowService: def __init__(self): 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: """ @@ -69,6 +80,18 @@ class EmailWorkflowService: results['workflows_executed'] += 1 results['workflows_succeeded'] += 1 logger.info("βœ… Bankruptcy system workflow executed successfully") + + # Special System Workflow: Helpdesk SAG routing + # - If SAG- 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 workflows = await self._find_matching_workflows(email_data) @@ -176,6 +199,188 @@ class EmailWorkflowService: '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]: """Find all workflows that match this email""" classification = email_data.get('classification') @@ -357,6 +562,7 @@ class EmailWorkflowService: handler_map = { 'create_ticket': self._action_create_ticket_system, '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, 'link_to_vendor': self._action_link_to_vendor, 'link_to_customer': self._action_link_to_customer, @@ -469,8 +675,8 @@ class EmailWorkflowService: 'body': email_data.get('body_text', ''), 'html_body': email_data.get('body_html'), '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 - 'references': None # TODO: Extract from email headers + 'in_reply_to': email_data.get('in_reply_to'), + 'references': email_data.get('email_references') } # Get params from workflow @@ -516,6 +722,8 @@ class EmailWorkflowService: 'body': email_data.get('body_text', ''), 'html_body': email_data.get('body_html'), '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}") @@ -594,13 +802,31 @@ class EmailWorkflowService: return {'action': 'link_to_vendor', 'matched': False, 'reason': 'Vendor not found'} async def _action_link_to_customer(self, params: Dict, email_data: Dict) -> Dict: - """Link email to customer""" - logger.info(f"πŸ”— Would link to customer") - - # TODO: Implement customer matching logic + """Link email to customer by sender domain and persist on email_messages""" + sender_domain = self._extract_sender_domain(email_data) + if not sender_domain: + 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 { '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: diff --git a/migrations/141_email_threading_headers.sql b/migrations/141_email_threading_headers.sql new file mode 100644 index 0000000..8b25875 --- /dev/null +++ b/migrations/141_email_threading_headers.sql @@ -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- threading lookup'; +COMMENT ON COLUMN email_messages.email_references IS 'Raw References header used for SAG- threading lookup';