feat(email): add functionality to send emails with attachments from case tab

This commit is contained in:
Christian 2026-03-17 21:51:43 +01:00
parent 1d7107bff0
commit 695854a272
8 changed files with 800 additions and 89 deletions

38
.github/skills/gui-starter/SKILL.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: gui-starter
description: "Use when building or updating BMC Hub GUI pages, templates, layout, styling, dark mode toggle, responsive Bootstrap 5 UI, or Nordic Top themed frontend components."
---
# BMC Hub GUI Starter
## Purpose
Use this skill when implementing or refining frontend UI in BMC Hub.
## Project UI Rules
- Follow the Nordic Top style from `docs/design_reference/`.
- Keep a minimalist, clean layout with card-based sections.
- Use Deep Blue as default primary accent: `#0f4c75`.
- Support dark mode with a visible toggle.
- Use CSS variables so accent colors can be changed dynamically.
- Build mobile-first with Bootstrap 5 grid utilities.
## Preferred Workflow
1. Identify existing template/page and preserve established structure when present.
2. Define or update theme tokens as CSS variables (light + dark).
3. Implement responsive layout first, then enhance desktop spacing/typography.
4. Add or maintain dark mode toggle logic (persist preference in localStorage when relevant).
5. Reuse patterns from `docs/design_reference/components.html`, `docs/design_reference/index.html`, `docs/design_reference/customers.html`, and `docs/design_reference/form.html`.
6. Validate visual consistency and avoid introducing one-off styles unless necessary.
## Implementation Guardrails
- Do not hardcode colors repeatedly; map them to CSS variables.
- Do not remove dark mode support from existing pages.
- Do not break existing navigation/topbar behavior.
- Avoid large framework changes unless explicitly requested.
- Keep accessibility basics in place: color contrast, visible focus states, semantic HTML.
## Deliverables
When using this skill, provide:
- Updated frontend files (HTML/CSS/JS) with concise, intentional styling.
- A short summary of what changed and why.
- Notes about any remaining UI tradeoffs or follow-up refinements.

View File

@ -105,6 +105,7 @@ class Settings(BaseSettings):
EMAIL_AI_ENABLED: bool = False EMAIL_AI_ENABLED: bool = False
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled) EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7 EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
EMAIL_REQUIRE_MANUAL_APPROVAL: bool = True # Phase 1: human approval before case creation/routing
EMAIL_MAX_FETCH_PER_RUN: int = 50 EMAIL_MAX_FETCH_PER_RUN: int = 50
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5 EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
EMAIL_WORKFLOWS_ENABLED: bool = True EMAIL_WORKFLOWS_ENABLED: bool = True

View File

@ -142,10 +142,115 @@ class ProcessingStats(BaseModel):
fetched: int = 0 fetched: int = 0
saved: int = 0 saved: int = 0
classified: int = 0 classified: int = 0
awaiting_user_action: int = 0
rules_matched: int = 0 rules_matched: int = 0
errors: int = 0 errors: int = 0
class CreateSagFromEmailRequest(BaseModel):
titel: Optional[str] = None
beskrivelse: Optional[str] = None
customer_id: Optional[int] = None
contact_id: Optional[int] = None
case_type: str = "support"
secondary_label: Optional[str] = None
start_date: Optional[date] = None
priority: Optional[str] = None
ansvarlig_bruger_id: Optional[int] = None
assigned_group_id: Optional[int] = None
created_by_user_id: int = 1
relation_type: str = "kommentar"
class LinkEmailToSagRequest(BaseModel):
sag_id: int
relation_type: str = "kommentar"
note: Optional[str] = None
forfatter: str = "E-mail Motor"
mark_processed: bool = True
@router.get("/emails/sag-options")
async def get_sag_assignment_options():
"""Return users and groups for SAG assignment controls in email UI."""
try:
users = execute_query(
"""
SELECT user_id AS id,
COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS name
FROM users
WHERE is_active = true
ORDER BY COALESCE(full_name, username, user_id::text)
"""
) or []
groups = execute_query(
"""
SELECT id, name
FROM groups
ORDER BY name
"""
) or []
return {"users": users, "groups": groups}
except Exception as e:
logger.error("❌ Error loading SAG assignment options: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/search-customers")
async def search_customers(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
"""Autocomplete customers for email-to-case flow."""
try:
like = f"%{q.strip()}%"
rows = execute_query(
"""
SELECT id, name, email_domain
FROM customers
WHERE (
name ILIKE %s
OR COALESCE(email, '') ILIKE %s
OR COALESCE(email_domain, '') ILIKE %s
OR COALESCE(cvr_number, '') ILIKE %s
)
ORDER BY name
LIMIT %s
""",
(like, like, like, like, limit)
)
return rows or []
except Exception as e:
logger.error("❌ Error searching customers from email router: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/search-sager")
async def search_sager(q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100)):
"""Autocomplete SAG cases for linking emails to existing cases."""
try:
like = f"%{q.strip()}%"
rows = execute_query(
"""
SELECT s.id, s.titel, s.status, s.priority, c.name AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND (
s.titel ILIKE %s
OR COALESCE(s.beskrivelse, '') ILIKE %s
OR CAST(s.id AS TEXT) ILIKE %s
)
ORDER BY s.updated_at DESC
LIMIT %s
""",
(like, like, like, limit)
)
return rows or []
except Exception as e:
logger.error("❌ Error searching sager from email router: %s", e)
raise HTTPException(status_code=500, detail=str(e))
# Email Endpoints # Email Endpoints
@router.get("/emails", response_model=List[EmailListItem]) @router.get("/emails", response_model=List[EmailListItem])
async def list_emails( async def list_emails(
@ -375,6 +480,201 @@ async def link_email(email_id: int, payload: Dict):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/create-sag")
async def create_sag_from_email(email_id: int, payload: CreateSagFromEmailRequest):
"""Create a new SAG from an email and persist the email-case relation."""
try:
email_row = execute_query(
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,)
)
if not email_row:
raise HTTPException(status_code=404, detail="Email not found")
email_data = email_row[0]
customer_id = payload.customer_id or email_data.get('customer_id')
if not customer_id:
raise HTTPException(status_code=400, detail="customer_id is required (missing on email and payload)")
titel = (payload.titel or email_data.get('subject') or f"E-mail fra {email_data.get('sender_email', 'ukendt afsender')}").strip()
beskrivelse = payload.beskrivelse or email_data.get('body_text') or email_data.get('body_html') or ''
template_key = (payload.case_type or 'support').strip().lower()[:50]
priority = (payload.priority or 'normal').strip().lower()
if priority not in {'low', 'normal', 'high', 'urgent'}:
raise HTTPException(status_code=400, detail="priority must be one of: low, normal, high, urgent")
case_result = execute_query(
"""
INSERT INTO sag_sager
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, priority, start_date)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, titel, customer_id, status, template_key, priority, start_date, created_at
""",
(
titel,
beskrivelse,
template_key,
'åben',
customer_id,
payload.ansvarlig_bruger_id,
payload.assigned_group_id,
payload.created_by_user_id,
priority,
payload.start_date,
)
)
if not case_result:
raise HTTPException(status_code=500, detail="Failed to create SAG")
sag = case_result[0]
sag_id = sag['id']
# Link email to SAG (audit trail)
execute_update(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT (sag_id, email_id) DO NOTHING
""",
(sag_id, email_id)
)
execute_update(
"""
UPDATE email_messages
SET linked_case_id = %s,
status = 'processed',
folder = 'Processed',
processed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(sag_id, email_id)
)
if payload.contact_id:
execute_update(
"""
INSERT INTO sag_kontakter (sag_id, contact_id, role)
VALUES (%s, %s, %s)
ON CONFLICT DO NOTHING
""",
(sag_id, payload.contact_id, 'primary')
)
relation_type = (payload.relation_type or 'kommentar').strip().lower()
if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
system_note = (
f"E-mail knyttet som {relation_type}.\n"
f"Emne: {email_data.get('subject') or '(ingen emne)'}\n"
f"Fra: {email_data.get('sender_email') or '(ukendt)'}"
)
if payload.secondary_label:
system_note += f"\nLabel: {payload.secondary_label.strip()[:60]}"
execute_update(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(sag_id, 'E-mail Motor', system_note, True)
)
return {
"success": True,
"email_id": email_id,
"sag": sag,
"message": "SAG oprettet fra e-mail"
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating SAG from email %s: %s", email_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/link-sag")
async def link_email_to_sag(email_id: int, payload: LinkEmailToSagRequest):
"""Link an email to an existing SAG and optionally append a system note."""
try:
email_row = execute_query(
"SELECT id, subject, sender_email FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,)
)
if not email_row:
raise HTTPException(status_code=404, detail="Email not found")
sag_row = execute_query(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(payload.sag_id,)
)
if not sag_row:
raise HTTPException(status_code=404, detail="SAG not found")
execute_update(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT (sag_id, email_id) DO NOTHING
""",
(payload.sag_id, email_id)
)
if payload.mark_processed:
execute_update(
"""
UPDATE email_messages
SET linked_case_id = %s,
status = 'processed',
folder = 'Processed',
processed_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(payload.sag_id, email_id)
)
else:
execute_update(
"""
UPDATE email_messages
SET linked_case_id = %s,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(payload.sag_id, email_id)
)
relation_type = (payload.relation_type or 'kommentar').strip().lower()
if relation_type in {'kommentar', 'intern_note', 'kundeopdatering'}:
email_data = email_row[0]
note = payload.note or (
f"E-mail knyttet som {relation_type}. "
f"Emne: {email_data.get('subject') or '(ingen emne)'}"
)
execute_update(
"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
""",
(payload.sag_id, payload.forfatter, note, True)
)
return {
"success": True,
"email_id": email_id,
"sag_id": payload.sag_id,
"message": "E-mail knyttet til SAG"
}
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error linking email %s to SAG %s: %s", email_id, payload.sag_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/extract-vendor-suggestion") @router.post("/emails/{email_id}/extract-vendor-suggestion")
async def extract_vendor_suggestion(email_id: int): async def extract_vendor_suggestion(email_id: int):
""" """
@ -1206,6 +1506,7 @@ async def get_processing_stats():
SELECT SELECT
COUNT(*) as total_emails, COUNT(*) as total_emails,
COUNT(*) FILTER (WHERE status = 'new') as new_emails, COUNT(*) FILTER (WHERE status = 'new') as new_emails,
COUNT(*) FILTER (WHERE status = 'awaiting_user_action') as awaiting_user_action,
COUNT(*) FILTER (WHERE status = 'processed') as processed_emails, COUNT(*) FILTER (WHERE status = 'processed') as processed_emails,
COUNT(*) FILTER (WHERE status = 'error') as error_emails, COUNT(*) FILTER (WHERE status = 'error') as error_emails,
COUNT(*) FILTER (WHERE has_attachments = true) as with_attachments, COUNT(*) FILTER (WHERE has_attachments = true) as with_attachments,
@ -1225,6 +1526,7 @@ async def get_processing_stats():
return { return {
"total_emails": 0, "total_emails": 0,
"new_emails": 0, "new_emails": 0,
"awaiting_user_action": 0,
"processed_emails": 0, "processed_emails": 0,
"error_emails": 0, "error_emails": 0,
"with_attachments": 0, "with_attachments": 0,
@ -1494,6 +1796,7 @@ async def get_email_stats():
SELECT SELECT
COUNT(*) as total_emails, COUNT(*) as total_emails,
COUNT(CASE WHEN status = 'new' THEN 1 END) as new_emails, COUNT(CASE WHEN status = 'new' THEN 1 END) as new_emails,
COUNT(CASE WHEN status = 'awaiting_user_action' THEN 1 END) as awaiting_user_action,
COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails, COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails,
COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices, COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices,
COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations, COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations,

View File

@ -436,6 +436,45 @@
color: var(--accent); color: var(--accent);
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.suggestion-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.suggestion-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.suggestion-field label {
font-size: 0.72rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.suggestion-field input,
.suggestion-field select {
border: 1px solid rgba(0,0,0,0.1);
border-radius: 8px;
background: var(--bg-body);
color: var(--text-primary);
padding: 0.45rem 0.6rem;
font-size: 0.85rem;
}
.suggestion-field.full {
grid-column: 1 / -1;
}
.quick-action-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 1200px) { @media (max-width: 1200px) {
@ -871,6 +910,9 @@
<button class="filter-pill" data-filter="processed" onclick="setFilter('processed')"> <button class="filter-pill" data-filter="processed" onclick="setFilter('processed')">
Behandlet <span class="count" id="countProcessed">0</span> Behandlet <span class="count" id="countProcessed">0</span>
</button> </button>
<button class="filter-pill" data-filter="awaiting_user_action" onclick="setFilter('awaiting_user_action')">
Afventer <span class="count" id="countAwaiting">0</span>
</button>
<button class="filter-pill" data-filter="case_notification" onclick="setFilter('case_notification')"> <button class="filter-pill" data-filter="case_notification" onclick="setFilter('case_notification')">
Sag <span class="count" id="countCase">0</span> Sag <span class="count" id="countCase">0</span>
</button> </button>
@ -1312,6 +1354,7 @@ let emails = [];
let selectedEmails = new Set(); let selectedEmails = new Set();
let emailSearchTimeout = null; let emailSearchTimeout = null;
let autoRefreshInterval = null; let autoRefreshInterval = null;
let sagAssignmentOptions = { users: [], groups: [] };
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -1322,6 +1365,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadStats(); loadStats();
setupEventListeners(); setupEventListeners();
setupKeyboardShortcuts(); setupKeyboardShortcuts();
preloadSagAssignmentOptions();
startAutoRefresh(); startAutoRefresh();
}); });
@ -1431,13 +1475,12 @@ async function loadEmails(searchQuery = '') {
// Handle special filters // Handle special filters
if (currentFilter === 'active') { if (currentFilter === 'active') {
// Show only new, error, or flagged (pending review) emails // Active queue includes both new and awaiting manual handling.
// If searching, ignore status filter to allow global search // We fetch list data and filter client-side because API status filter is single-value.
if (!searchQuery) {
url += '&status=new';
}
} else if (currentFilter === 'processed') { } else if (currentFilter === 'processed') {
url += '&status=processed'; url += '&status=processed';
} else if (currentFilter === 'awaiting_user_action') {
url += '&status=awaiting_user_action';
} else if (currentFilter !== 'all') { } else if (currentFilter !== 'all') {
// Classification filter // Classification filter
url += `&classification=${currentFilter}`; url += `&classification=${currentFilter}`;
@ -1455,6 +1498,11 @@ async function loadEmails(searchQuery = '') {
} }
emails = await response.json(); emails = await response.json();
if (currentFilter === 'active' && !searchQuery) {
emails = emails.filter((email) => ['new', 'awaiting_user_action'].includes(email.status || 'new'));
}
console.log('Loaded emails:', emails.length, 'items'); console.log('Loaded emails:', emails.length, 'items');
renderEmailList(emails); renderEmailList(emails);
@ -1757,52 +1805,131 @@ function renderEmailAnalysis(email) {
console.error('aiAnalysisTab element not found in DOM'); console.error('aiAnalysisTab element not found in DOM');
return; return;
} }
const classification = email.classification || 'general'; const classification = email.classification || 'general';
const confidence = email.confidence_score || 0; const confidence = email.confidence_score || 0;
const primaryType = suggestPrimaryType(email);
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar const secondaryLabel = suggestSecondaryLabel(email);
aiAnalysisTab.innerHTML = ` const selectedCustomerName = email.customer_name || '';
${!email.customer_id && !email.supplier_id ? `
<div class="analysis-card border border-warning">
<h6 class="text-warning"><i class="bi bi-person-question-fill me-2"></i>Ukendt Afsender</h6>
<div class="text-muted small mb-2">
<i class="bi bi-envelope me-1"></i>${escapeHtml(email.sender_email || '')}
${email.sender_name ? `<br><i class="bi bi-person me-1"></i>${escapeHtml(email.sender_name)}` : ''}
</div>
<div class="d-flex flex-column gap-2">
<button class="btn btn-sm btn-outline-primary w-100"
onclick="quickCreateCustomer(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
<i class="bi bi-person-plus me-2"></i>Opret som Kunde
</button>
<button class="btn btn-sm btn-outline-secondary w-100"
onclick="quickCreateVendor(${email.id}, '${escapeHtml(email.sender_name || '')}', '${escapeHtml(email.sender_email || '')}')">
<i class="bi bi-shop me-2"></i>Opret som Leverandør
</button>
</div>
</div>
` : `
<div class="analysis-card">
<h6 class="text-success"><i class="bi bi-person-check-fill me-2"></i>Linket Til</h6>
<ul class="metadata-list">
${email.customer_id ? `<li class="metadata-item"><div class="metadata-label">Kunde</div><div class="metadata-value">${escapeHtml(email.customer_name || '#' + email.customer_id)}</div></li>` : ''}
${email.supplier_id ? `<li class="metadata-item"><div class="metadata-label">Leverandør</div><div class="metadata-value">${escapeHtml(email.supplier_name || '#' + email.supplier_id)}</div></li>` : ''}
</ul>
</div>
`}
${getClassificationActions(email) ? ` const userOptions = (sagAssignmentOptions.users || []).map((user) =>
`<option value="${user.id}">${escapeHtml(user.name)}</option>`
).join('');
const groupOptions = (sagAssignmentOptions.groups || []).map((group) =>
`<option value="${group.id}">${escapeHtml(group.name)}</option>`
).join('');
aiAnalysisTab.innerHTML = `
<div class="analysis-card"> <div class="analysis-card">
<h6><i class="bi bi-lightning-charge-fill me-2"></i>Hurtig Action</h6> <h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
<div class="d-flex flex-column gap-2"> <div class="quick-action-row mb-3">
${getClassificationActions(email)} <button class="btn btn-sm btn-primary" onclick="confirmSuggestion()">
<i class="bi bi-check2-circle me-1"></i>Bekræft Forslag
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="focusTypeEditor()">
<i class="bi bi-arrow-repeat me-1"></i>Ret Type
</button>
<button class="btn btn-sm btn-outline-danger" onclick="markAsSpam()">
<i class="bi bi-slash-circle me-1"></i>Spam
</button>
</div>
<div class="suggestion-grid">
<div class="suggestion-field">
<label for="casePrimaryType">Primær Type</label>
<select id="casePrimaryType">
<option value="support" ${primaryType === 'support' ? 'selected' : ''}>Support</option>
<option value="bogholderi" ${primaryType === 'bogholderi' ? 'selected' : ''}>Bogholderi</option>
<option value="leverandor" ${primaryType === 'leverandor' ? 'selected' : ''}>Leverandør</option>
<option value="helhedsopgave" ${primaryType === 'helhedsopgave' ? 'selected' : ''}>Helhedsopgave</option>
<option value="andet" ${primaryType === 'andet' ? 'selected' : ''}>Andet</option>
</select>
</div>
<div class="suggestion-field">
<label for="caseSecondaryLabel">Sekundær Label</label>
<input id="caseSecondaryLabel" type="text" maxlength="60" value="${escapeHtml(secondaryLabel)}" placeholder="fx Fakturaspørgsmål">
</div>
<div class="suggestion-field full">
<label for="caseCustomerSearch">Kunde</label>
<input id="caseCustomerSearch" list="caseCustomerResults" value="${escapeHtml(selectedCustomerName)}" placeholder="Søg kunde..." oninput="searchCustomersForCurrentEmail(this.value)">
<datalist id="caseCustomerResults"></datalist>
<input id="caseCustomerId" type="hidden" value="${email.customer_id || ''}">
</div>
<div class="suggestion-field">
<label for="caseAssignee">Ansvarlig Bruger</label>
<select id="caseAssignee">
<option value="">Ingen bruger</option>
${userOptions}
</select>
</div>
<div class="suggestion-field">
<label for="caseGroup">Gruppe</label>
<select id="caseGroup">
<option value="">Ingen gruppe</option>
${groupOptions}
</select>
</div>
<div class="suggestion-field">
<label for="caseStartDate">Startdato</label>
<input id="caseStartDate" type="date" value="${todayAsDateString()}">
</div>
<div class="suggestion-field">
<label for="casePriority">Prioritet</label>
<select id="casePriority">
<option value="low">Lav</option>
<option value="normal" selected>Normal</option>
<option value="high">Høj</option>
<option value="urgent">Akut</option>
</select>
</div>
<div class="suggestion-field full">
<label for="caseTitle">Titel</label>
<input id="caseTitle" type="text" value="${escapeHtml((email.subject || `E-mail fra ${email.sender_email || 'ukendt'}`).slice(0, 250))}">
</div>
</div>
<div class="quick-action-row mt-3">
<button class="btn btn-sm btn-primary" onclick="createCaseFromCurrentForm()">
<i class="bi bi-plus-circle me-1"></i>Opret Ny Sag
</button>
<button class="btn btn-sm btn-outline-primary" onclick="toggleLinkExistingPanel()">
<i class="bi bi-link-45deg me-1"></i>Tilknyt Eksisterende Sag
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="setPrimaryType('support')">Support</button>
<button class="btn btn-sm btn-outline-secondary" onclick="setPrimaryType('leverandor')">Leverandør</button>
</div>
<div id="linkExistingPanel" class="mt-3" style="display:none;">
<div class="suggestion-field full">
<label for="existingSagSearch">Søg Sag</label>
<input id="existingSagSearch" list="existingSagResults" placeholder="Søg på titel eller ID..." oninput="searchSagerForCurrentEmail(this.value)">
<datalist id="existingSagResults"></datalist>
<input id="existingSagId" type="hidden" value="">
</div>
<div class="suggestion-field full mt-2">
<label for="existingSagRelationType">Tilføj mail som</label>
<select id="existingSagRelationType">
<option value="kommentar">Kommentar</option>
<option value="intern_note">Intern note</option>
<option value="kundeopdatering">Kundeopdatering</option>
</select>
</div>
<button class="btn btn-sm btn-primary mt-2" onclick="linkCurrentEmailToExistingSag()">
<i class="bi bi-link me-1"></i>Tilknyt Sag
</button>
</div> </div>
</div> </div>
` : ''}
<div class="analysis-card"> <div class="analysis-card">
<h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6> <h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6>
<div class="confidence-meter"> <div class="confidence-meter">
<div class="confidence-bar"> <div class="confidence-bar">
<div class="confidence-fill" style="width: ${confidence * 100}%"></div> <div class="confidence-fill" style="width: ${confidence * 100}%"></div>
@ -1812,7 +1939,7 @@ function renderEmailAnalysis(email) {
<span><strong>${Math.round(confidence * 100)}%</strong></span> <span><strong>${Math.round(confidence * 100)}%</strong></span>
</div> </div>
</div> </div>
<select class="classification-select" id="classificationSelect" onchange="updateClassification()"> <select class="classification-select" id="classificationSelect" onchange="updateClassification()">
<option value="invoice" ${classification === 'invoice' ? 'selected' : ''}>📄 Faktura</option> <option value="invoice" ${classification === 'invoice' ? 'selected' : ''}>📄 Faktura</option>
<option value="order_confirmation" ${classification === 'order_confirmation' ? 'selected' : ''}>📦 Ordrebekræftelse</option> <option value="order_confirmation" ${classification === 'order_confirmation' ? 'selected' : ''}>📦 Ordrebekræftelse</option>
@ -1824,52 +1951,193 @@ function renderEmailAnalysis(email) {
<option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option> <option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option>
<option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option> <option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>
</select> </select>
<button class="btn btn-sm btn-primary w-100" onclick="saveClassification()"> <button class="btn btn-sm btn-primary w-100" onclick="saveClassification()">
<i class="bi bi-check-lg me-2"></i>Gem Klassificering <i class="bi bi-check-lg me-2"></i>Gem Klassificering
</button> </button>
</div> </div>
<div class="analysis-card">
<h6><i class="bi bi-info-circle me-2"></i>Metadata</h6>
<ul class="metadata-list">
<li class="metadata-item">
<div class="metadata-label">Message ID</div>
<div class="metadata-value" style="font-size: 0.7rem; word-break: break-all;">${email.message_id || 'N/A'}</div>
</li>
<li class="metadata-item">
<div class="metadata-label">Modtaget</div>
<div class="metadata-value">${formatDateTime(email.received_date)}</div>
</li>
<li class="metadata-item">
<div class="metadata-label">Status</div>
<div class="metadata-value">${email.status || 'new'}</div>
</li>
${email.extracted_invoice_number ? `
<li class="metadata-item">
<div class="metadata-label">Fakturanummer</div>
<div class="metadata-value">${email.extracted_invoice_number}</div>
</li>
` : ''}
${email.extracted_amount ? `
<li class="metadata-item">
<div class="metadata-label">Beløb</div>
<div class="metadata-value">${email.extracted_amount} ${email.extracted_currency || 'DKK'}</div>
</li>
` : ''}
</ul>
</div>
${email.matched_rules && email.matched_rules.length > 0 ? `
<div class="analysis-card">
<h6><i class="bi bi-lightning-charge me-2"></i>Matchede Regler</h6>
<div class="rules-indicator">
<i class="bi bi-check-circle-fill"></i>
<span>${email.matched_rules.length} regel(er) matchet</span>
</div>
</div>
` : ''}
`; `;
const statusBadge = document.getElementById('emailActionStatus');
if (statusBadge) {
statusBadge.textContent = email.status || 'new';
}
}
function todayAsDateString() {
return new Date().toISOString().split('T')[0];
}
function suggestPrimaryType(email) {
const classification = (email.classification || '').toLowerCase();
if (classification === 'invoice') return 'bogholderi';
if (classification === 'time_confirmation') return 'support';
if (classification === 'case_notification') return 'support';
if (email.supplier_id) return 'leverandor';
return 'support';
}
function suggestSecondaryLabel(email) {
const classification = (email.classification || '').toLowerCase();
const mapping = {
invoice: 'Fakturasporgsmal',
time_confirmation: 'Tidsbekraftelse',
case_notification: 'Sag opdatering',
order_confirmation: 'Ordre bekraftelse',
freight_note: 'Fragt opdatering',
general: 'Generel henvendelse'
};
return mapping[classification] || 'Kunde henvendelse';
}
async function preloadSagAssignmentOptions() {
try {
const response = await fetch('/api/v1/emails/sag-options');
if (!response.ok) return;
sagAssignmentOptions = await response.json();
} catch (error) {
console.warn('Could not preload sag assignment options:', error);
}
}
function focusTypeEditor() {
document.getElementById('casePrimaryType')?.focus();
}
function setPrimaryType(value) {
const el = document.getElementById('casePrimaryType');
if (el) el.value = value;
}
function toggleLinkExistingPanel() {
const panel = document.getElementById('linkExistingPanel');
if (!panel) return;
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
function getSelectedIdFromDatalist(inputId, datalistId) {
const input = document.getElementById(inputId);
const datalist = document.getElementById(datalistId);
if (!input || !datalist) return null;
const option = [...datalist.options].find((opt) => opt.value === input.value);
return option ? option.dataset.id : null;
}
async function searchCustomersForCurrentEmail(query) {
if (!query || query.length < 2) return;
try {
const response = await fetch(`/api/v1/emails/search-customers?q=${encodeURIComponent(query)}`);
if (!response.ok) return;
const customers = await response.json();
const datalist = document.getElementById('caseCustomerResults');
if (!datalist) return;
datalist.innerHTML = customers.map((customer) => {
const display = `${customer.name} (#${customer.id})`;
return `<option value="${escapeHtml(display)}" data-id="${customer.id}"></option>`;
}).join('');
} catch (error) {
console.warn('Customer search failed:', error);
}
}
async function searchSagerForCurrentEmail(query) {
if (!query || query.length < 2) return;
try {
const response = await fetch(`/api/v1/emails/search-sager?q=${encodeURIComponent(query)}`);
if (!response.ok) return;
const sager = await response.json();
const datalist = document.getElementById('existingSagResults');
if (!datalist) return;
datalist.innerHTML = sager.map((sag) => {
const display = `SAG-${sag.id}: ${sag.titel || '(uden titel)'}`;
return `<option value="${escapeHtml(display)}" data-id="${sag.id}"></option>`;
}).join('');
} catch (error) {
console.warn('SAG search failed:', error);
}
}
function confirmSuggestion() {
createCaseFromCurrentForm();
}
function getCaseFormPayload() {
const customerIdHidden = document.getElementById('caseCustomerId')?.value;
const customerIdFromSearch = getSelectedIdFromDatalist('caseCustomerSearch', 'caseCustomerResults');
const resolvedCustomerId = customerIdFromSearch || customerIdHidden || null;
return {
titel: document.getElementById('caseTitle')?.value?.trim() || null,
customer_id: resolvedCustomerId ? Number(resolvedCustomerId) : null,
case_type: document.getElementById('casePrimaryType')?.value || 'support',
secondary_label: document.getElementById('caseSecondaryLabel')?.value?.trim() || null,
start_date: document.getElementById('caseStartDate')?.value || null,
priority: document.getElementById('casePriority')?.value || 'normal',
ansvarlig_bruger_id: document.getElementById('caseAssignee')?.value ? Number(document.getElementById('caseAssignee').value) : null,
assigned_group_id: document.getElementById('caseGroup')?.value ? Number(document.getElementById('caseGroup').value) : null,
relation_type: 'kommentar'
};
}
async function createCaseFromCurrentForm() {
if (!currentEmailId) return;
const payload = getCaseFormPayload();
if (!payload.customer_id) {
showError('Vælg kunde før sag-oprettelse');
return;
}
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/create-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Sag-oprettelse fejlede');
}
const result = await response.json();
showSuccess(`SAG-${result.sag.id} oprettet og e-mail linket`);
loadEmails();
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunne ikke oprette sag');
}
}
async function linkCurrentEmailToExistingSag() {
if (!currentEmailId) return;
const selectedSagId = getSelectedIdFromDatalist('existingSagSearch', 'existingSagResults') || document.getElementById('existingSagId')?.value;
if (!selectedSagId) {
showError('Vælg en eksisterende sag først');
return;
}
try {
const response = await fetch(`/api/v1/emails/${currentEmailId}/link-sag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: Number(selectedSagId),
relation_type: document.getElementById('existingSagRelationType')?.value || 'kommentar'
})
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Tilknytning fejlede');
}
showSuccess(`E-mail knyttet til SAG-${selectedSagId}`);
loadEmails();
await loadEmailDetail(currentEmailId);
} catch (error) {
showError(error.message || 'Kunne ikke knytte e-mail til sag');
}
} }
function showEmptyState() { function showEmptyState() {
@ -1914,12 +2182,14 @@ async function loadStats() {
const response = await fetch('/api/v1/emails/stats/summary'); const response = await fetch('/api/v1/emails/stats/summary');
const stats = await response.json(); const stats = await response.json();
// Calculate active emails (new + error + flagged) const newCount = stats.new_emails || 0;
const activeCount = stats.new_emails || 0; const awaitingCount = stats.awaiting_user_action || 0;
const activeCount = newCount + awaitingCount;
document.getElementById('countActive').textContent = activeCount; document.getElementById('countActive').textContent = activeCount;
document.getElementById('countAll').textContent = stats.total_emails || 0; document.getElementById('countAll').textContent = stats.total_emails || 0;
document.getElementById('countProcessed').textContent = stats.processed_emails || 0; document.getElementById('countProcessed').textContent = stats.processed_emails || 0;
document.getElementById('countAwaiting').textContent = awaitingCount;
document.getElementById('countInvoice').textContent = stats.invoices || 0; document.getElementById('countInvoice').textContent = stats.invoices || 0;
document.getElementById('countOrder').textContent = 0; document.getElementById('countOrder').textContent = 0;
document.getElementById('countFreight').textContent = 0; document.getElementById('countFreight').textContent = 0;
@ -2272,11 +2542,20 @@ async function createTimeEntry(emailId) {
} }
async function createCase(emailId) { async function createCase(emailId) {
showError('Sags-modul er ikke implementeret endnu'); if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
setPrimaryType('support');
focusTypeEditor();
showInfo('Sagsforslag klar. Udfyld felter og klik Opret Ny Sag.');
} }
async function linkToCustomer(emailId) { async function linkToCustomer(emailId) {
showError('Kunde-linking er ikke implementeret endnu'); if (currentEmailId !== emailId) {
await selectEmail(emailId);
}
document.getElementById('caseCustomerSearch')?.focus();
showInfo('Søg og vælg kunde i forslagspanelet.');
} }
// ─── Quick Create Customer ──────────────────────────────────────────────── // ─── Quick Create Customer ────────────────────────────────────────────────
@ -2688,6 +2967,10 @@ function formatClassification(classification) {
function getStatusBadge(email) { function getStatusBadge(email) {
const status = email.status || 'new'; const status = email.status || 'new';
const approvalStatus = email.approval_status; const approvalStatus = email.approval_status;
if (status === 'awaiting_user_action') {
return '<span class="badge bg-warning text-dark badge-sm ms-1"><i class="bi bi-person-check me-1"></i>Afventer</span>';
}
if (status === 'processed' || status === 'archived') { if (status === 'processed' || status === 'archived') {
return '<span class="badge bg-success badge-sm ms-1"><i class="bi bi-check-circle me-1"></i>Behandlet</span>'; return '<span class="badge bg-success badge-sm ms-1"><i class="bi bi-check-circle me-1"></i>Behandlet</span>';

View File

@ -49,6 +49,7 @@ class EmailProcessorService:
'fetched': 0, 'fetched': 0,
'saved': 0, 'saved': 0,
'classified': 0, 'classified': 0,
'awaiting_user_action': 0,
'rules_matched': 0, 'rules_matched': 0,
'errors': 0 'errors': 0
} }
@ -86,6 +87,8 @@ class EmailProcessorService:
if result.get('classified'): if result.get('classified'):
stats['classified'] += 1 stats['classified'] += 1
if result.get('awaiting_user_action'):
stats['awaiting_user_action'] += 1
if result.get('rules_matched'): if result.get('rules_matched'):
stats['rules_matched'] += 1 stats['rules_matched'] += 1
@ -109,6 +112,7 @@ class EmailProcessorService:
email_id = email_data.get('id') email_id = email_data.get('id')
stats = { stats = {
'classified': False, 'classified': False,
'awaiting_user_action': False,
'workflows_executed': 0, 'workflows_executed': 0,
'rules_matched': False 'rules_matched': False
} }
@ -123,6 +127,22 @@ class EmailProcessorService:
if settings.EMAIL_AUTO_CLASSIFY: if settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data) await self._classify_and_update(email_data)
stats['classified'] = True stats['classified'] = True
# Step 3.5: Gate automation by manual-approval policy and confidence
# Phase-1 policy: suggestions are generated automatically, actions are user-approved.
classification = (email_data.get('classification') or '').strip().lower()
confidence = float(email_data.get('confidence_score') or 0.0)
require_manual_approval = getattr(settings, 'EMAIL_REQUIRE_MANUAL_APPROVAL', True)
if require_manual_approval:
await self._set_awaiting_user_action(email_id, reason='manual_approval_required')
stats['awaiting_user_action'] = True
return stats
if not classification or confidence < settings.EMAIL_AI_CONFIDENCE_THRESHOLD:
await self._set_awaiting_user_action(email_id, reason='low_confidence')
stats['awaiting_user_action'] = True
return stats
# Step 4: Execute workflows based on classification # Step 4: Execute workflows based on classification
workflow_processed = False workflow_processed = False
@ -172,6 +192,25 @@ class EmailProcessorService:
except Exception as e: except Exception as e:
logger.error(f"❌ Error in process_single_email for {email_id}: {e}") logger.error(f"❌ Error in process_single_email for {email_id}: {e}")
raise e raise e
async def _set_awaiting_user_action(self, email_id: Optional[int], reason: str):
"""Park an email for manual review before any automatic routing/action."""
if not email_id:
return
execute_update(
"""
UPDATE email_messages
SET status = 'awaiting_user_action',
folder = COALESCE(folder, 'INBOX'),
auto_processed = false,
processed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
""",
(email_id,)
)
logger.info("🛑 Email %s moved to awaiting_user_action (%s)", email_id, reason)
async def _classify_and_update(self, email_data: Dict): async def _classify_and_update(self, email_data: Dict):
"""Classify email and update database""" """Classify email and update database"""

View File

@ -0,0 +1,37 @@
# Email Feature Backup
Backup artifact for current email handling implementation.
## Artifact
- `email_feature_backup_20260317_214413.zip`
## Contents
- `app/emails/`
- `app/services/email_service.py`
- `app/services/email_processor_service.py`
- `app/services/email_analysis_service.py`
- `app/services/email_workflow_service.py`
- `app/services/email_activity_logger.py`
- `app/modules/sag/templates/detail.html`
- `migrations/013_email_system.sql`
- `migrations/014_email_workflows.sql`
- `migrations/050_email_activity_log.sql`
- `migrations/056_email_import_method.sql`
- `migrations/084_sag_files_and_emails.sql`
- `migrations/140_email_extracted_vendor_fields.sql`
- `migrations/141_email_threading_headers.sql`
## Restore (code only)
From repository root:
```bash
unzip -o backups/email_feature/email_feature_backup_20260317_214413.zip -d .
```
## Notes
- This restore only replaces code files included in the artifact.
- Database rollback must be handled separately if schema/data has changed.

View File

@ -0,0 +1,10 @@
-- Migration 145: Add start date to SAG for email-driven case creation flow
ALTER TABLE sag_sager
ADD COLUMN IF NOT EXISTS start_date DATE;
CREATE INDEX IF NOT EXISTS idx_sag_sager_start_date
ON sag_sager(start_date)
WHERE deleted_at IS NULL;
COMMENT ON COLUMN sag_sager.start_date IS 'Planned start date for case execution (used by email-to-case workflow).';