diff --git a/app/emails/backend/router.py b/app/emails/backend/router.py
index 3263f24..3a7276e 100644
--- a/app/emails/backend/router.py
+++ b/app/emails/backend/router.py
@@ -5,13 +5,14 @@ API endpoints for email viewing, classification, and rule management
import logging
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
-from typing import List, Optional
+from typing import List, Optional, Dict
from pydantic import BaseModel
from datetime import datetime, date
from app.core.database import execute_query, execute_insert, execute_update
from app.services.email_processor_service import EmailProcessorService
from app.services.email_workflow_service import email_workflow_service
+from app.services.ollama_service import ollama_service
logger = logging.getLogger(__name__)
@@ -76,6 +77,8 @@ class EmailDetail(BaseModel):
created_at: datetime
updated_at: datetime
attachments: List[EmailAttachment] = []
+ customer_name: Optional[str] = None
+ supplier_name: Optional[str] = None
class EmailRule(BaseModel):
@@ -204,8 +207,13 @@ async def get_email(email_id: int):
"""Get email detail by ID"""
try:
query = """
- SELECT * FROM email_messages
- WHERE id = %s AND deleted_at IS NULL
+ SELECT em.*,
+ c.name AS customer_name,
+ v.name AS supplier_name
+ FROM email_messages em
+ LEFT JOIN customers c ON em.customer_id = c.id
+ LEFT JOIN vendors v ON em.supplier_id = v.id
+ WHERE em.id = %s AND em.deleted_at IS NULL
"""
result = execute_query(query, (email_id,))
logger.info(f"🔍 Query result type: {type(result)}, length: {len(result) if result else 0}")
@@ -334,6 +342,161 @@ async def update_email(email_id: int, status: Optional[str] = None):
raise HTTPException(status_code=500, detail=str(e))
+@router.patch("/emails/{email_id}/link")
+async def link_email(email_id: int, payload: Dict):
+ """Link email to a customer and/or vendor/supplier"""
+ try:
+ updates = []
+ params = []
+
+ if 'customer_id' in payload:
+ updates.append("customer_id = %s")
+ params.append(payload['customer_id'])
+
+ if 'supplier_id' in payload:
+ updates.append("supplier_id = %s")
+ params.append(payload['supplier_id'])
+
+ if not updates:
+ raise HTTPException(status_code=400, detail="Ingen felter at opdatere")
+
+ params.append(email_id)
+ query = f"UPDATE email_messages SET {', '.join(updates)}, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
+ execute_update(query, tuple(params))
+
+ logger.info(f"✅ Linked email {email_id}: {payload}")
+ return {"success": True, "message": "Email linket"}
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error linking email {email_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/emails/{email_id}/extract-vendor-suggestion")
+async def extract_vendor_suggestion(email_id: int):
+ """
+ Forsøger at udtrække leverandørinfo fra email body og vedhæftede fakturaer.
+ Returnerer forslag til navn, CVR, adresse, telefon, email, domæne.
+ """
+ import re
+ import os
+
+ try:
+ # Hent email
+ email_result = execute_query(
+ "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL",
+ (email_id,)
+ )
+ if not email_result:
+ raise HTTPException(status_code=404, detail="Email ikke fundet")
+ email = email_result[0]
+
+ # Saml tekst fra body + vedhæftede PDF-filer
+ text_parts = []
+ if email.get('body_text'):
+ text_parts.append(email['body_text'])
+
+ attachments = execute_query(
+ "SELECT * FROM email_attachments WHERE email_id = %s ORDER BY id",
+ (email_id,)
+ )
+ for att in (attachments or []):
+ file_path = att.get('file_path')
+ if file_path and os.path.exists(file_path):
+ content_type = att.get('content_type', '')
+ if 'pdf' in content_type or file_path.lower().endswith('.pdf'):
+ try:
+ pdf_text = await ollama_service._extract_text_from_file(file_path)
+ if pdf_text:
+ text_parts.append(f"[PDF: {att['filename']}]\n{pdf_text}")
+ except Exception as e:
+ logger.warning(f"⚠️ Kunne ikke læse PDF {file_path}: {e}")
+
+ combined_text = "\n\n".join(text_parts)
+
+ suggestion = {
+ "name": email.get('sender_name') or '',
+ "email": email.get('sender_email') or '',
+ "cvr_number": None,
+ "phone": None,
+ "address": None,
+ "domain": None,
+ "source": "regex"
+ }
+
+ # Regex fallback: uddrag CVR (8 cifre efter CVR/Momsnr/DK)
+ cvr_match = re.search(
+ r'(?:CVR|Cvr|cvr|Momsnr\.?|DK)[\s:.-]*([0-9]{8})',
+ combined_text
+ )
+ if cvr_match:
+ suggestion['cvr_number'] = cvr_match.group(1)
+
+ # Regex: telefon (dansk format)
+ phone_match = re.search(
+ r'(?:Tlf|Tel|Telefon|Phone)[\s.:]*([+\d][\d\s\-().]{6,15})',
+ combined_text, re.IGNORECASE
+ )
+ if phone_match:
+ suggestion['phone'] = phone_match.group(1).strip()
+
+ # Domæne fra sender email
+ if email.get('sender_email') and '@' in email['sender_email']:
+ suggestion['domain'] = email['sender_email'].split('@')[1]
+
+ # Brug AI hvis vi har tekst fra PDF
+ if len(combined_text) > 100:
+ try:
+ from app.core.config import settings
+ own_cvr = getattr(settings, 'OWN_CVR', '')
+
+ prompt = f"""OPGAVE: Udtræk leverandørens firmainfo fra denne tekst.
+TEKSTEN er body/footer fra en faktura-email eller vedhæftet faktura.
+RETURNER KUN VALID JSON - ingen forklaring!
+
+{{
+ \"name\": \"Firmanavn ApS\",
+ \"cvr_number\": \"12345678\",
+ \"address\": \"Vejnavn 1, 2000 By\",
+ \"phone\": \"12345678\",
+ \"email\": \"kontakt@firma.dk\",
+ \"domain\": \"firma.dk\"
+}}
+
+REGLER:
+- name: LEVERANDØRENS firmanavn (ikke køber, ikke BMC)
+- cvr_number: 8-cifret CVR - IGNORER {own_cvr} (det er køber)
+- Sæt null hvis ikke fundet
+- KUN JSON output!
+
+TEKST (max 3000 tegn):
+{combined_text[:3000]}
+
+KUN JSON:"""
+
+ ai_result = await ollama_service.extract_from_text(prompt)
+ if ai_result and isinstance(ai_result, dict):
+ # Merge AI resultat ind i suggestion (AI prioriteres over regex)
+ for field in ('name', 'cvr_number', 'address', 'phone', 'email', 'domain'):
+ val = ai_result.get(field)
+ if val and val not in (None, 'null', '', 'N/A'):
+ suggestion[field] = str(val).strip()
+ suggestion['source'] = 'ai'
+ logger.info(f"✅ AI vendor suggestion for email {email_id}: {suggestion}")
+ except Exception as e:
+ logger.warning(f"⚠️ AI udtræk fejlede, bruger regex-resultat: {e}")
+
+ return suggestion
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ extract-vendor-suggestion fejlede for email {email_id}: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.delete("/emails/{email_id}")
async def delete_email(email_id: int):
"""Soft delete email"""
diff --git a/app/emails/frontend/emails.html b/app/emails/frontend/emails.html
index d5b1dac..308664d 100644
--- a/app/emails/frontend/emails.html
+++ b/app/emails/frontend/emails.html
@@ -1763,6 +1763,34 @@ function renderEmailAnalysis(email) {
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar
aiAnalysisTab.innerHTML = `
+ ${!email.customer_id && !email.supplier_id ? `
+
+
Ukendt Afsender
+
+ ${escapeHtml(email.sender_email || '')}
+ ${email.sender_name ? `
${escapeHtml(email.sender_name)}` : ''}
+
+
+
+
+
+
+ ` : `
+
+ `}
+
${getClassificationActions(email) ? `
Hurtig Action
@@ -2251,6 +2279,141 @@ async function linkToCustomer(emailId) {
showError('Kunde-linking er ikke implementeret endnu');
}
+// ─── Quick Create Customer ────────────────────────────────────────────────
+function quickCreateCustomer(emailId, senderName, senderEmail) {
+ document.getElementById('qcEmailId').value = emailId;
+ document.getElementById('qcCustomerName').value = senderName || '';
+ document.getElementById('qcCustomerEmail').value = senderEmail || '';
+ document.getElementById('qcCustomerPhone').value = '';
+ document.getElementById('qcCustomerStatus').textContent = '';
+ const modal = new bootstrap.Modal(document.getElementById('quickCreateCustomerModal'));
+ modal.show();
+}
+
+async function submitQuickCustomer() {
+ const emailId = document.getElementById('qcEmailId').value;
+ const name = document.getElementById('qcCustomerName').value.trim();
+ const email = document.getElementById('qcCustomerEmail').value.trim();
+ const phone = document.getElementById('qcCustomerPhone').value.trim();
+ const statusEl = document.getElementById('qcCustomerStatus');
+
+ if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
+ statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
+
+ try {
+ // Create customer
+ const custResp = await fetch('/api/v1/customers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, email: email || null, phone: phone || null })
+ });
+ if (!custResp.ok) throw new Error((await custResp.json()).detail || 'Opret fejlede');
+ const customer = await custResp.json();
+
+ // Link email
+ await fetch(`/api/v1/emails/${emailId}/link`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ customer_id: customer.id })
+ });
+
+ bootstrap.Modal.getInstance(document.getElementById('quickCreateCustomerModal')).hide();
+ showSuccess(`Kunde "${name}" oprettet og linket`);
+ loadEmailDetail(parseInt(emailId));
+ } catch (e) {
+ statusEl.className = 'text-danger small';
+ statusEl.textContent = e.message;
+ }
+}
+
+// ─── Quick Create Vendor ──────────────────────────────────────────────────
+async function quickCreateVendor(emailId, senderName, senderEmail) {
+ // Reset form
+ document.getElementById('qvEmailId').value = emailId;
+ document.getElementById('qvVendorName').value = senderName || '';
+ document.getElementById('qvVendorEmail').value = senderEmail || '';
+ document.getElementById('qvVendorCVR').value = '';
+ document.getElementById('qvVendorPhone').value = '';
+ document.getElementById('qvVendorAddress').value = '';
+ document.getElementById('qvVendorDomain').value = senderEmail && senderEmail.includes('@') ? senderEmail.split('@')[1] : '';
+ document.getElementById('qvVendorStatus').textContent = '';
+ document.getElementById('qvAiStatus').innerHTML = '
Henter info fra email/bilag…';
+
+ const modal = new bootstrap.Modal(document.getElementById('quickCreateVendorModal'));
+ modal.show();
+
+ // Attempt AI extraction in background
+ try {
+ const resp = await fetch(`/api/v1/emails/${emailId}/extract-vendor-suggestion`, { method: 'POST' });
+ if (resp.ok) {
+ const s = await resp.json();
+ if (s.name && s.name !== senderName) document.getElementById('qvVendorName').value = s.name;
+ if (s.cvr_number) document.getElementById('qvVendorCVR').value = s.cvr_number;
+ if (s.phone) document.getElementById('qvVendorPhone').value = s.phone;
+ if (s.address) document.getElementById('qvVendorAddress').value = s.address;
+ if (s.domain) document.getElementById('qvVendorDomain').value = s.domain;
+ if (s.email && !document.getElementById('qvVendorEmail').value)
+ document.getElementById('qvVendorEmail').value = s.email;
+
+ const srcLabel = s.source === 'ai' ? '🤖 AI' : '🔍 Regex';
+ document.getElementById('qvAiStatus').innerHTML =
+ `
${srcLabel} – felter preudfyldt`;
+ } else {
+ document.getElementById('qvAiStatus').innerHTML =
+ '
Ingen automatisk data fundet';
+ }
+ } catch (e) {
+ document.getElementById('qvAiStatus').innerHTML =
+ '
Udfyld manuelt';
+ }
+}
+
+async function submitQuickVendor() {
+ const emailId = document.getElementById('qvEmailId').value;
+ const name = document.getElementById('qvVendorName').value.trim();
+ const email = document.getElementById('qvVendorEmail').value.trim();
+ const cvr = document.getElementById('qvVendorCVR').value.trim();
+ const phone = document.getElementById('qvVendorPhone').value.trim();
+ const address = document.getElementById('qvVendorAddress').value.trim();
+ const domain = document.getElementById('qvVendorDomain').value.trim();
+ const statusEl = document.getElementById('qvVendorStatus');
+
+ if (!name) { statusEl.className = 'text-danger small'; statusEl.textContent = 'Navn er påkrævet'; return; }
+ statusEl.className = 'text-muted small'; statusEl.textContent = 'Opretter…';
+
+ try {
+ // Create vendor
+ const vendResp = await fetch('/api/v1/vendors', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name,
+ email: email || null,
+ cvr_number: cvr || null,
+ phone: phone || null,
+ address: address || null,
+ domain: domain || null
+ })
+ });
+ if (!vendResp.ok) throw new Error((await vendResp.json()).detail || 'Opret fejlede');
+ const vendor = await vendResp.json();
+
+ // Link email
+ await fetch(`/api/v1/emails/${emailId}/link`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ supplier_id: vendor.id })
+ });
+
+ bootstrap.Modal.getInstance(document.getElementById('quickCreateVendorModal')).hide();
+ showSuccess(`Leverandør "${name}" oprettet og linket`);
+ loadEmailDetail(parseInt(emailId));
+ } catch (e) {
+ statusEl.className = 'text-danger small';
+ statusEl.textContent = e.message;
+ }
+}
+
async function saveClassification() {
if (!currentEmailId) return;
@@ -4115,4 +4278,88 @@ async function uploadEmailFiles(files) {
}
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% endblock %}
diff --git a/app/models/schemas.py b/app/models/schemas.py
index 64d81fb..4c28053 100644
--- a/app/models/schemas.py
+++ b/app/models/schemas.py
@@ -74,9 +74,16 @@ class VendorBase(BaseModel):
domain: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
+ address: Optional[str] = None
+ postal_code: Optional[str] = None
+ city: Optional[str] = None
+ website: Optional[str] = None
+ email_pattern: Optional[str] = None
contact_person: Optional[str] = None
category: Optional[str] = None
+ priority: Optional[int] = 100
notes: Optional[str] = None
+ is_active: bool = True
class VendorCreate(VendorBase):
@@ -100,10 +107,9 @@ class VendorUpdate(BaseModel):
class Vendor(VendorBase):
"""Full vendor schema"""
id: int
- is_active: bool = True
created_at: datetime
updated_at: Optional[datetime] = None
-
+
class Config:
from_attributes = True