feat: Implement email management UI with FastAPI and keyword-based classification
- Added FastAPI router for serving email management UI at /emails - Created Jinja2 template for the email frontend - Developed SimpleEmailClassifier for keyword-based email classification - Documented email UI implementation details, features, and API integration in EMAIL_UI_IMPLEMENTATION.md
This commit is contained in:
parent
8791e34f4e
commit
7f325b5c32
@ -720,8 +720,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadVendors();
|
||||
setDefaultDates();
|
||||
loadPendingFilesCount(); // Load count for badge
|
||||
checkEmailContext(); // Check if coming from email
|
||||
});
|
||||
|
||||
// Check if coming from email context
|
||||
function checkEmailContext() {
|
||||
const emailContext = sessionStorage.getItem('supplierInvoiceContext');
|
||||
if (emailContext) {
|
||||
try {
|
||||
const context = JSON.parse(emailContext);
|
||||
|
||||
// Show notification
|
||||
showSuccess(`Opret faktura fra email: ${context.subject}`);
|
||||
|
||||
// Pre-fill description field with email subject
|
||||
const descriptionField = document.getElementById('description');
|
||||
if (descriptionField) {
|
||||
descriptionField.value = `Fra email: ${context.subject}\nAfsender: ${context.sender}`;
|
||||
}
|
||||
|
||||
// Open create modal if exists
|
||||
const createModal = new bootstrap.Modal(document.getElementById('invoiceModal'));
|
||||
createModal.show();
|
||||
|
||||
// Clear context after use
|
||||
sessionStorage.removeItem('supplierInvoiceContext');
|
||||
} catch (error) {
|
||||
console.error('Failed to parse email context:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set default dates
|
||||
function setDefaultDates() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
@ -9,7 +9,7 @@ from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
|
||||
from app.core.database import execute_query, execute_insert
|
||||
from app.core.database import execute_query, execute_insert, execute_update
|
||||
from app.services.email_processor_service import EmailProcessorService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -36,6 +36,16 @@ class EmailListItem(BaseModel):
|
||||
customer_name: Optional[str] = None
|
||||
|
||||
|
||||
class EmailAttachment(BaseModel):
|
||||
id: int
|
||||
email_id: int
|
||||
filename: str
|
||||
content_type: Optional[str]
|
||||
size_bytes: Optional[int]
|
||||
file_path: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmailDetail(BaseModel):
|
||||
id: int
|
||||
message_id: str
|
||||
@ -64,6 +74,7 @@ class EmailDetail(BaseModel):
|
||||
auto_processed: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
attachments: List[EmailAttachment] = []
|
||||
|
||||
|
||||
class EmailRule(BaseModel):
|
||||
@ -146,15 +157,24 @@ async def get_email(email_id: int):
|
||||
WHERE id = %s AND 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}")
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Email not found")
|
||||
|
||||
# Store email before update
|
||||
email_data = result[0]
|
||||
|
||||
# Get attachments
|
||||
att_query = "SELECT * FROM email_attachments WHERE email_id = %s ORDER BY id"
|
||||
attachments = execute_query(att_query, (email_id,))
|
||||
email_data['attachments'] = attachments or []
|
||||
|
||||
# Mark as read
|
||||
update_query = "UPDATE email_messages SET is_read = true WHERE id = %s"
|
||||
execute_query(update_query, (email_id,))
|
||||
execute_update(update_query, (email_id,))
|
||||
|
||||
return result[0]
|
||||
return email_data
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
@ -163,6 +183,136 @@ async def get_email(email_id: int):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/emails/{email_id}/attachments/{attachment_id}")
|
||||
async def download_attachment(email_id: int, attachment_id: int):
|
||||
"""Download email attachment"""
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
|
||||
try:
|
||||
query = """
|
||||
SELECT a.* FROM email_attachments a
|
||||
JOIN email_messages e ON e.id = a.email_id
|
||||
WHERE a.id = %s AND a.email_id = %s AND e.deleted_at IS NULL
|
||||
"""
|
||||
result = execute_query(query, (attachment_id, email_id))
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
attachment = result[0]
|
||||
file_path = attachment['file_path']
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=attachment['filename'],
|
||||
media_type=attachment.get('content_type', 'application/octet-stream')
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error downloading attachment {attachment_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/emails/{email_id}")
|
||||
async def update_email(email_id: int, status: Optional[str] = None):
|
||||
"""Update email (archive, mark as read, etc)"""
|
||||
try:
|
||||
# Build update fields dynamically
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if status:
|
||||
updates.append("status = %s")
|
||||
params.append(status)
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
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"✅ Updated email {email_id}: status={status}")
|
||||
return {"success": True, "message": "Email updated"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating 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"""
|
||||
try:
|
||||
query = """
|
||||
UPDATE email_messages
|
||||
SET deleted_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
"""
|
||||
execute_update(query, (email_id,))
|
||||
|
||||
logger.info(f"🗑️ Deleted email {email_id}")
|
||||
return {"success": True, "message": "Email deleted"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error deleting email {email_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/{email_id}/reprocess")
|
||||
async def reprocess_email(email_id: int):
|
||||
"""Reprocess email (re-classify and apply rules)"""
|
||||
try:
|
||||
# Get email
|
||||
query = "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL"
|
||||
result = execute_query(query, (email_id,))
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Email not found")
|
||||
|
||||
email = result[0]
|
||||
|
||||
# Re-classify
|
||||
processor = EmailProcessorService()
|
||||
classification, confidence = await processor.classify_email(
|
||||
email['subject'],
|
||||
email['body_text'] or email['body_html']
|
||||
)
|
||||
|
||||
# Update classification
|
||||
update_query = """
|
||||
UPDATE email_messages
|
||||
SET classification = %s,
|
||||
confidence_score = %s,
|
||||
classification_date = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
"""
|
||||
execute_update(update_query, (classification, confidence, email_id))
|
||||
|
||||
logger.info(f"🔄 Reprocessed email {email_id}: {classification} ({confidence:.2f})")
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Email reprocessed",
|
||||
"classification": classification,
|
||||
"confidence": confidence
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error reprocessing email {email_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/process")
|
||||
async def process_emails():
|
||||
"""Manually trigger email processing"""
|
||||
@ -181,25 +331,101 @@ async def process_emails():
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/{email_id}/reprocess")
|
||||
async def reprocess_email(email_id: int):
|
||||
"""Manually reprocess a single email (reclassify + rematch rules)"""
|
||||
@router.post("/emails/bulk/archive")
|
||||
async def bulk_archive(email_ids: List[int]):
|
||||
"""Bulk archive emails"""
|
||||
try:
|
||||
processor = EmailProcessorService()
|
||||
await processor.reprocess_email(email_id)
|
||||
if not email_ids:
|
||||
raise HTTPException(status_code=400, detail="No email IDs provided")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Email {email_id} reprocessed successfully"
|
||||
}
|
||||
placeholders = ','.join(['%s'] * len(email_ids))
|
||||
query = f"""
|
||||
UPDATE email_messages
|
||||
SET status = 'archived', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id IN ({placeholders}) AND deleted_at IS NULL
|
||||
"""
|
||||
execute_update(query, tuple(email_ids))
|
||||
|
||||
logger.info(f"📦 Bulk archived {len(email_ids)} emails")
|
||||
return {"success": True, "message": f"{len(email_ids)} emails archived"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error reprocessing email {email_id}: {e}")
|
||||
logger.error(f"❌ Error bulk archiving: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/bulk/reprocess")
|
||||
async def bulk_reprocess(email_ids: List[int]):
|
||||
"""Bulk reprocess emails"""
|
||||
try:
|
||||
if not email_ids:
|
||||
raise HTTPException(status_code=400, detail="No email IDs provided")
|
||||
|
||||
processor = EmailProcessorService()
|
||||
success_count = 0
|
||||
|
||||
for email_id in email_ids:
|
||||
try:
|
||||
# Get email
|
||||
query = "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL"
|
||||
result = execute_query(query, (email_id,))
|
||||
|
||||
if result:
|
||||
email = result[0]
|
||||
classification, confidence = await processor.classify_email(
|
||||
email['subject'],
|
||||
email['body_text'] or email['body_html']
|
||||
)
|
||||
|
||||
update_query = """
|
||||
UPDATE email_messages
|
||||
SET classification = %s, confidence_score = %s,
|
||||
classification_date = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
"""
|
||||
execute_update(update_query, (classification, confidence, email_id))
|
||||
success_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reprocess email {email_id}: {e}")
|
||||
|
||||
logger.info(f"🔄 Bulk reprocessed {success_count}/{len(email_ids)} emails")
|
||||
return {"success": True, "message": f"{success_count} emails reprocessed"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error bulk reprocessing: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/bulk/delete")
|
||||
async def bulk_delete(email_ids: List[int]):
|
||||
"""Bulk soft delete emails"""
|
||||
try:
|
||||
if not email_ids:
|
||||
raise HTTPException(status_code=400, detail="No email IDs provided")
|
||||
|
||||
placeholders = ','.join(['%s'] * len(email_ids))
|
||||
query = f"""
|
||||
UPDATE email_messages
|
||||
SET deleted_at = CURRENT_TIMESTAMP
|
||||
WHERE id IN ({placeholders}) AND deleted_at IS NULL
|
||||
"""
|
||||
execute_update(query, tuple(email_ids))
|
||||
|
||||
logger.info(f"🗑️ Bulk deleted {len(email_ids)} emails")
|
||||
return {"success": True, "message": f"{len(email_ids)} emails deleted"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error bulk deleting: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
class ClassificationUpdate(BaseModel):
|
||||
classification: str
|
||||
confidence: Optional[float] = None
|
||||
|
||||
|
||||
@router.put("/emails/{email_id}/classify")
|
||||
async def update_classification(email_id: int, classification: str):
|
||||
async def update_classification(email_id: int, data: ClassificationUpdate):
|
||||
"""Manually update email classification"""
|
||||
try:
|
||||
valid_classifications = [
|
||||
@ -207,20 +433,24 @@ async def update_classification(email_id: int, classification: str):
|
||||
'case_notification', 'customer_email', 'bankruptcy', 'general', 'spam', 'unknown'
|
||||
]
|
||||
|
||||
if classification not in valid_classifications:
|
||||
if data.classification not in valid_classifications:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid classification. Must be one of: {valid_classifications}")
|
||||
|
||||
confidence = data.confidence if data.confidence is not None else 1.0
|
||||
|
||||
query = """
|
||||
UPDATE email_messages
|
||||
SET classification = %s,
|
||||
confidence_score = %s,
|
||||
classification_date = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
"""
|
||||
execute_query(query, (classification, email_id))
|
||||
execute_update(query, (data.classification, confidence, email_id))
|
||||
|
||||
logger.info(f"✏️ Manual classification: Email {email_id} → {data.classification}")
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Email {email_id} classified as '{classification}'"
|
||||
"message": f"Email {email_id} classified as '{data.classification}'"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
|
||||
1776
app/emails/frontend/emails.html
Normal file
1776
app/emails/frontend/emails.html
Normal file
File diff suppressed because it is too large
Load Diff
24
app/emails/frontend/views.py
Normal file
24
app/emails/frontend/views.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""
|
||||
Email Frontend Views
|
||||
Serves the email management UI
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Setup Jinja2 templates
|
||||
templates = Jinja2Templates(directory="app")
|
||||
|
||||
@router.get("/emails", response_class=HTMLResponse)
|
||||
async def emails_page(request: Request):
|
||||
"""Email management UI - 3-column modern email interface"""
|
||||
return templates.TemplateResponse(
|
||||
"emails/frontend/emails.html",
|
||||
{"request": request}
|
||||
)
|
||||
@ -59,40 +59,21 @@ class EmailAnalysisService:
|
||||
|
||||
def _build_classification_prompt(self) -> str:
|
||||
"""Build Danish system prompt for email classification"""
|
||||
return """Du er en ekspert i at klassificere danske forretningsemails.
|
||||
return """Classify this Danish business email into ONE category. Return ONLY valid JSON with no explanation.
|
||||
|
||||
Din opgave er at analysere emailens indhold og klassificere den i én af følgende kategorier:
|
||||
Categories: invoice, freight_note, order_confirmation, time_confirmation, case_notification, customer_email, bankruptcy, general, spam, unknown
|
||||
|
||||
**Kategorier:**
|
||||
1. **invoice** - Fakturaer fra leverandører (inkl. kreditnotaer)
|
||||
2. **freight_note** - Fragtbreve og forsendelsesbekræftelser
|
||||
3. **order_confirmation** - Ordrebekræftelser fra leverandører
|
||||
4. **time_confirmation** - Bekræftelser på tidsforbrug/timer (fra kunder eller interne)
|
||||
5. **case_notification** - Notifikationer om sager, support tickets, opgaver
|
||||
6. **customer_email** - Generelle kundehenvendelser (spørgsmål, feedback, klager)
|
||||
7. **bankruptcy** - Konkursmeldinger, rekonstruktion, betalingsstandsning
|
||||
8. **general** - Almindelig kommunikation (opdateringer, møder, newsletters)
|
||||
9. **spam** - Spam, reklame, phishing
|
||||
10. **unknown** - Kan ikke klassificeres med sikkerhed
|
||||
Rules:
|
||||
- invoice: Contains invoice number, amount, or payment info
|
||||
- time_confirmation: Time/hours confirmation, often with case references
|
||||
- case_notification: Notifications about specific cases (CC0001, Case #123)
|
||||
- bankruptcy: Explicit bankruptcy/insolvency notice
|
||||
- Be conservative: Use general or unknown if uncertain
|
||||
|
||||
**Vigtige regler:**
|
||||
- `invoice` skal indeholde fakturanummer, beløb, eller betalingsinformation
|
||||
- `time_confirmation` indeholder timer/tidsforbrug, ofte med case/sagsreferencer
|
||||
- `case_notification` er notifikationer om specifikke sager (CC0001, Case #123 osv.)
|
||||
- `bankruptcy` kun hvis der er EKSPLICIT konkursmelding
|
||||
- Vær konservativ: Hvis du er i tvivl, brug `general` eller `unknown`
|
||||
Response format (JSON only, no other text):
|
||||
{"classification": "invoice", "confidence": 0.95, "reasoning": "Subject contains 'Faktura' and invoice number"}
|
||||
|
||||
**Output format (JSON):**
|
||||
```json
|
||||
{
|
||||
"classification": "invoice",
|
||||
"confidence": 0.95,
|
||||
"reasoning": "Emailen indeholder fakturanummer, beløb og betalingsinstruktioner"
|
||||
}
|
||||
```
|
||||
|
||||
Returner KUN JSON - ingen anden tekst.
|
||||
"""
|
||||
IMPORTANT: Return ONLY the JSON object. Do not include any explanation, thinking, or additional text."""
|
||||
|
||||
def _build_email_context(self, email_data: Dict) -> str:
|
||||
"""Build email context for AI analysis"""
|
||||
@ -130,7 +111,7 @@ Klassificer denne email."""
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": 0.1, # Low temperature for consistent classification
|
||||
"num_predict": 200 # Short response expected
|
||||
"num_predict": 500 # Enough for complete JSON response
|
||||
}
|
||||
}
|
||||
|
||||
@ -145,10 +126,19 @@ Klassificer denne email."""
|
||||
return None
|
||||
|
||||
data = await response.json()
|
||||
content = data.get('message', {}).get('content', '')
|
||||
|
||||
message_data = data.get('message', {})
|
||||
|
||||
# qwen3 model returns 'thinking' field instead of 'content' for reasoning
|
||||
# Try both fields
|
||||
content = message_data.get('content', '') or message_data.get('thinking', '')
|
||||
|
||||
processing_time = (datetime.now() - start_time).total_seconds() * 1000
|
||||
|
||||
if not content:
|
||||
logger.error(f"❌ Ollama returned empty response. Message keys: {message_data.keys()}")
|
||||
return None
|
||||
|
||||
# Parse JSON response
|
||||
result = self._parse_ollama_response(content)
|
||||
|
||||
@ -157,7 +147,7 @@ Klassificer denne email."""
|
||||
logger.info(f"✅ AI classification: {result['classification']} (confidence: {result['confidence']}, {processing_time:.0f}ms)")
|
||||
return result
|
||||
else:
|
||||
logger.error(f"❌ Failed to parse Ollama response: {content[:100]}")
|
||||
logger.error(f"❌ Failed to parse Ollama response. Content length: {len(content)}, First 300 chars: {content[:300]}")
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
|
||||
@ -10,8 +10,9 @@ from datetime import datetime
|
||||
|
||||
from app.services.email_service import EmailService
|
||||
from app.services.email_analysis_service import EmailAnalysisService
|
||||
from app.services.simple_classifier import simple_classifier
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query
|
||||
from app.core.database import execute_query, execute_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -25,6 +26,7 @@ class EmailProcessorService:
|
||||
self.enabled = settings.EMAIL_TO_TICKET_ENABLED
|
||||
self.rules_enabled = settings.EMAIL_RULES_ENABLED
|
||||
self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS
|
||||
self.ai_enabled = settings.EMAIL_AI_ENABLED
|
||||
|
||||
async def process_inbox(self) -> Dict:
|
||||
"""
|
||||
@ -93,8 +95,14 @@ class EmailProcessorService:
|
||||
async def _classify_and_update(self, email_data: Dict):
|
||||
"""Classify email and update database"""
|
||||
try:
|
||||
# Run AI classification
|
||||
logger.info(f"🔍 _classify_and_update: ai_enabled={self.ai_enabled}, EMAIL_AI_ENABLED={settings.EMAIL_AI_ENABLED}")
|
||||
|
||||
# Run classification (AI or simple keyword-based)
|
||||
if self.ai_enabled:
|
||||
result = await self.analysis_service.classify_email(email_data)
|
||||
else:
|
||||
logger.info(f"🔍 Using simple keyword classifier for email {email_data['id']}")
|
||||
result = simple_classifier.classify(email_data)
|
||||
|
||||
classification = result.get('classification', 'unknown')
|
||||
confidence = result.get('confidence', 0.0)
|
||||
@ -107,7 +115,7 @@ class EmailProcessorService:
|
||||
classification_date = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
"""
|
||||
execute_query(query, (classification, confidence, email_data['id']))
|
||||
execute_update(query, (classification, confidence, email_data['id']))
|
||||
|
||||
logger.info(f"✅ Classified email {email_data['id']} as '{classification}' (confidence: {confidence:.2f})")
|
||||
|
||||
@ -420,8 +428,8 @@ class EmailProcessorService:
|
||||
|
||||
email_data = result[0]
|
||||
|
||||
# Reclassify
|
||||
if settings.EMAIL_AI_ENABLED:
|
||||
# Reclassify (either AI or keyword-based)
|
||||
if settings.EMAIL_AUTO_CLASSIFY:
|
||||
await self._classify_and_update(email_data)
|
||||
|
||||
# Rematch rules
|
||||
|
||||
@ -12,6 +12,7 @@ from typing import List, Dict, Optional, Tuple
|
||||
from datetime import datetime
|
||||
import json
|
||||
import asyncio
|
||||
import base64
|
||||
from aiohttp import ClientSession, BasicAuth
|
||||
import msal
|
||||
|
||||
@ -180,6 +181,19 @@ class EmailService:
|
||||
try:
|
||||
parsed_email = self._parse_graph_message(msg)
|
||||
|
||||
# Fetch attachments if email has them
|
||||
if msg.get('hasAttachments', False):
|
||||
attachments = await self._fetch_graph_attachments(
|
||||
user_email,
|
||||
msg['id'],
|
||||
access_token,
|
||||
session
|
||||
)
|
||||
parsed_email['attachments'] = attachments
|
||||
parsed_email['attachment_count'] = len(attachments)
|
||||
else:
|
||||
parsed_email['attachments'] = []
|
||||
|
||||
# Check if already exists
|
||||
if not self._email_exists(parsed_email['message_id']):
|
||||
emails.append(parsed_email)
|
||||
@ -274,17 +288,35 @@ class EmailService:
|
||||
except Exception:
|
||||
body_text = str(msg.get_payload())
|
||||
|
||||
# Check for attachments
|
||||
has_attachments = False
|
||||
attachment_count = 0
|
||||
# Extract attachments
|
||||
attachments = []
|
||||
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
if part.get_content_maintype() == 'multipart':
|
||||
continue
|
||||
if part.get('Content-Disposition') is not None:
|
||||
has_attachments = True
|
||||
attachment_count += 1
|
||||
|
||||
# Skip text parts (body content)
|
||||
if part.get_content_type() in ['text/plain', 'text/html']:
|
||||
continue
|
||||
|
||||
# Check if part has a filename (indicates attachment)
|
||||
filename = part.get_filename()
|
||||
if filename:
|
||||
# Decode filename if needed
|
||||
filename = self._decode_header(filename)
|
||||
|
||||
# Get attachment content
|
||||
content = part.get_payload(decode=True)
|
||||
content_type = part.get_content_type()
|
||||
|
||||
if content: # Only add if we got content
|
||||
attachments.append({
|
||||
'filename': filename,
|
||||
'content': content,
|
||||
'content_type': content_type,
|
||||
'size': len(content)
|
||||
})
|
||||
|
||||
return {
|
||||
'message_id': message_id,
|
||||
@ -297,8 +329,9 @@ class EmailService:
|
||||
'body_html': body_html,
|
||||
'received_date': received_date,
|
||||
'folder': self.imap_config['folder'],
|
||||
'has_attachments': has_attachments,
|
||||
'attachment_count': attachment_count
|
||||
'has_attachments': len(attachments) > 0,
|
||||
'attachment_count': len(attachments),
|
||||
'attachments': attachments
|
||||
}
|
||||
|
||||
def _parse_graph_message(self, msg: Dict) -> Dict:
|
||||
@ -341,9 +374,58 @@ class EmailService:
|
||||
'received_date': received_date,
|
||||
'folder': self.imap_config['folder'],
|
||||
'has_attachments': msg.get('hasAttachments', False),
|
||||
'attachment_count': 0 # TODO: Fetch attachment count from Graph API if needed
|
||||
'attachment_count': 0 # Will be updated after fetching attachments
|
||||
}
|
||||
|
||||
async def _fetch_graph_attachments(
|
||||
self,
|
||||
user_email: str,
|
||||
message_id: str,
|
||||
access_token: str,
|
||||
session: ClientSession
|
||||
) -> List[Dict]:
|
||||
"""Fetch attachments for a specific message from Graph API"""
|
||||
attachments = []
|
||||
|
||||
try:
|
||||
# Graph API endpoint for message attachments
|
||||
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages/{message_id}/attachments"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
async with session.get(url, headers=headers) as response:
|
||||
if response.status != 200:
|
||||
logger.warning(f"⚠️ Failed to fetch attachments for message {message_id}: {response.status}")
|
||||
return []
|
||||
|
||||
data = await response.json()
|
||||
attachment_list = data.get('value', [])
|
||||
|
||||
for att in attachment_list:
|
||||
# Graph API returns base64 content in contentBytes
|
||||
content_bytes = att.get('contentBytes', '')
|
||||
if content_bytes:
|
||||
import base64
|
||||
content = base64.b64decode(content_bytes)
|
||||
else:
|
||||
content = b''
|
||||
|
||||
attachments.append({
|
||||
'filename': att.get('name', 'unknown'),
|
||||
'content': content,
|
||||
'content_type': att.get('contentType', 'application/octet-stream'),
|
||||
'size': att.get('size', len(content))
|
||||
})
|
||||
|
||||
logger.info(f"📎 Fetched attachment: {att.get('name')} ({att.get('size', 0)} bytes)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error fetching attachments for message {message_id}: {e}")
|
||||
|
||||
return attachments
|
||||
|
||||
def _decode_header(self, header: str) -> str:
|
||||
"""Decode email header (handles MIME encoding)"""
|
||||
if not header:
|
||||
@ -425,12 +507,60 @@ class EmailService:
|
||||
))
|
||||
|
||||
logger.info(f"✅ Saved email {email_id}: {email_data['subject'][:50]}...")
|
||||
|
||||
# Save attachments if any
|
||||
if email_data.get('attachments'):
|
||||
await self._save_attachments(email_id, email_data['attachments'])
|
||||
|
||||
return email_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error saving email to database: {e}")
|
||||
return None
|
||||
|
||||
async def _save_attachments(self, email_id: int, attachments: List[Dict]):
|
||||
"""Save email attachments to disk and database"""
|
||||
import os
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
# Create uploads directory if not exists
|
||||
upload_dir = Path("uploads/email_attachments")
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for att in attachments:
|
||||
try:
|
||||
filename = att['filename']
|
||||
content = att['content']
|
||||
content_type = att.get('content_type', 'application/octet-stream')
|
||||
size_bytes = att['size']
|
||||
|
||||
# Generate MD5 hash for deduplication
|
||||
md5_hash = hashlib.md5(content).hexdigest()
|
||||
|
||||
# Save to disk with hash prefix
|
||||
file_path = upload_dir / f"{md5_hash}_{filename}"
|
||||
file_path.write_bytes(content)
|
||||
|
||||
# Save to database
|
||||
query = """
|
||||
INSERT INTO email_attachments
|
||||
(email_id, filename, content_type, size_bytes, file_path)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
"""
|
||||
execute_insert(query, (
|
||||
email_id,
|
||||
filename,
|
||||
content_type,
|
||||
size_bytes,
|
||||
str(file_path)
|
||||
))
|
||||
|
||||
logger.info(f"📎 Saved attachment: {filename} ({size_bytes} bytes)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to save attachment {filename}: {e}")
|
||||
|
||||
async def get_unprocessed_emails(self, limit: int = 100) -> List[Dict]:
|
||||
"""Get emails from database that haven't been processed yet"""
|
||||
query = """
|
||||
|
||||
109
app/services/simple_classifier.py
Normal file
109
app/services/simple_classifier.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""
|
||||
Simple Keyword-Based Email Classifier
|
||||
Fallback when AI classification is unavailable
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional
|
||||
import re
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SimpleEmailClassifier:
|
||||
"""Simple rule-based email classifier using keywords"""
|
||||
|
||||
def __init__(self):
|
||||
self.keyword_rules = {
|
||||
'invoice': [
|
||||
'faktura', 'invoice', 'kreditnota', 'credit note',
|
||||
'ordrenr', 'order number', 'betalingspåmindelse', 'payment reminder',
|
||||
'fakturanr', 'invoice number', 'betaling', 'payment'
|
||||
],
|
||||
'freight_note': [
|
||||
'fragtbrev', 'tracking', 'forsendelse', 'shipment',
|
||||
'levering', 'delivery', 'pakke', 'package', 'fragtbreve'
|
||||
],
|
||||
'order_confirmation': [
|
||||
'ordrebekræftelse', 'order confirmation', 'bestilling bekræftet',
|
||||
'ordre modtaget', 'order received'
|
||||
],
|
||||
'time_confirmation': [
|
||||
'timer', 'hours', 'tidsforbrug', 'time spent',
|
||||
'tidsregistrering', 'time registration'
|
||||
],
|
||||
'case_notification': [
|
||||
'cc[0-9]{4}', 'case #', 'sag ', 'ticket', 'support'
|
||||
],
|
||||
'bankruptcy': [
|
||||
'konkurs', 'bankruptcy', 'rekonstruktion', 'insolvency',
|
||||
'betalingsstandsning', 'administration'
|
||||
],
|
||||
'spam': [
|
||||
'unsubscribe', 'click here', 'free offer', 'gratis tilbud',
|
||||
'vind nu', 'win now', 'limited time'
|
||||
]
|
||||
}
|
||||
|
||||
def classify(self, email_data: Dict) -> Dict:
|
||||
"""
|
||||
Classify email using simple keyword matching
|
||||
Returns: {classification: str, confidence: float, reasoning: str}
|
||||
"""
|
||||
subject = (email_data.get('subject', '') or '').lower()
|
||||
sender = (email_data.get('sender_email', '') or '').lower()
|
||||
body = (email_data.get('body_text', '') or '').lower()[:500] # First 500 chars
|
||||
|
||||
logger.info(f"🔍 simple_classifier: subject='{subject}', body_len={len(body)}, sender='{sender}'")
|
||||
|
||||
# Combine all text for analysis
|
||||
text = f"{subject} {body}"
|
||||
|
||||
# Check each category
|
||||
scores = {}
|
||||
for category, keywords in self.keyword_rules.items():
|
||||
matches = 0
|
||||
matched_keywords = []
|
||||
|
||||
for keyword in keywords:
|
||||
# Use regex for patterns like CC[0-9]{4}
|
||||
if re.search(keyword, text, re.IGNORECASE):
|
||||
matches += 1
|
||||
matched_keywords.append(keyword)
|
||||
|
||||
if matches > 0:
|
||||
scores[category] = {
|
||||
'matches': matches,
|
||||
'keywords': matched_keywords
|
||||
}
|
||||
|
||||
# Determine best match
|
||||
if not scores:
|
||||
return {
|
||||
'classification': 'general',
|
||||
'confidence': 0.5,
|
||||
'reasoning': 'No specific keywords matched - classified as general'
|
||||
}
|
||||
|
||||
# Get category with most matches
|
||||
best_category = max(scores.items(), key=lambda x: x[1]['matches'])
|
||||
category_name = best_category[0]
|
||||
match_count = best_category[1]['matches']
|
||||
matched_keywords = best_category[1]['keywords']
|
||||
|
||||
# Calculate confidence (0.6-0.9 based on matches)
|
||||
confidence = min(0.9, 0.6 + (match_count * 0.1))
|
||||
|
||||
reasoning = f"Matched {match_count} keyword(s): {', '.join(matched_keywords[:3])}"
|
||||
|
||||
logger.info(f"✅ Keyword classification: {category_name} (confidence: {confidence:.2f})")
|
||||
|
||||
return {
|
||||
'classification': category_name,
|
||||
'confidence': confidence,
|
||||
'reasoning': reasoning
|
||||
}
|
||||
|
||||
|
||||
# Global instance
|
||||
simple_classifier = SimpleEmailClassifier()
|
||||
@ -212,6 +212,11 @@
|
||||
<li><a class="dropdown-item py-2" href="#">Rapporter</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/emails">
|
||||
<i class="bi bi-envelope me-2"></i>Email
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<button class="btn btn-light rounded-circle border-0" id="darkModeToggle" style="background: var(--accent-light); color: var(--accent);">
|
||||
|
||||
199
docs/EMAIL_UI_IMPLEMENTATION.md
Normal file
199
docs/EMAIL_UI_IMPLEMENTATION.md
Normal file
@ -0,0 +1,199 @@
|
||||
# Email Management UI - Implementation Complete
|
||||
|
||||
## Overview
|
||||
|
||||
Modern Gmail/Outlook-style email interface with 3-column layout, AI classification, and power-user keyboard shortcuts.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### ✅ Core UI Components
|
||||
|
||||
1. **3-Column Layout**
|
||||
- Left sidebar (320px): Email list with sender avatars, classification badges, unread indicators
|
||||
- Center pane (flex): Email content with HTML/text rendering, attachments, action buttons
|
||||
- Right sidebar (300px): AI analysis panel with confidence meter, classification editor, metadata
|
||||
|
||||
2. **Email List (Left Sidebar)**
|
||||
- Search bar with 300ms debounce
|
||||
- Filter pills: Alle, Faktura, Ordre, Fragt, Tid, Sag, Generel, Spam
|
||||
- Email items show: sender avatar (initials), subject (bold if unread), preview, time ago, classification badge
|
||||
- Active selection highlighting
|
||||
- Bulk selection checkboxes
|
||||
|
||||
3. **Email Content (Center Pane)**
|
||||
- Header with subject, sender details, timestamp
|
||||
- Action toolbar: Archive, Mark Spam, Reprocess, Delete
|
||||
- Body renderer: HTML iframe OR plain text pre-wrap
|
||||
- Attachments section with download links and file type icons
|
||||
- Empty state when no email selected
|
||||
|
||||
4. **AI Analysis (Right Sidebar)**
|
||||
- Confidence meter with gradient progress bar (red → yellow → green)
|
||||
- Classification dropdown with 8 categories
|
||||
- "Gem Klassificering" button
|
||||
- Metadata list: Message ID, received date, status, extracted invoice data
|
||||
- Matched rules indicator
|
||||
|
||||
5. **Bulk Actions Toolbar**
|
||||
- Appears when emails selected
|
||||
- Shows count of selected emails
|
||||
- Actions: Archive, Reprocess, Delete
|
||||
- "Select All" checkbox
|
||||
|
||||
6. **Keyboard Shortcuts**
|
||||
- `j/↓` - Next email
|
||||
- `k/↑` - Previous email
|
||||
- `Enter` - Open email
|
||||
- `e` - Archive
|
||||
- `r` - Reprocess
|
||||
- `c` - Focus classification dropdown
|
||||
- `x` - Toggle selection
|
||||
- `/` or `Cmd+K` - Focus search
|
||||
- `Esc` - Clear selection
|
||||
- `?` - Show shortcuts help modal
|
||||
|
||||
7. **Modals**
|
||||
- Email Rules modal (list/create/edit/delete rules)
|
||||
- Keyboard shortcuts help modal
|
||||
|
||||
### ✅ Backend Integration
|
||||
|
||||
- **API Endpoints**: All 11 email endpoints from `app/emails/backend/router.py`
|
||||
- **Frontend Route**: `/emails` serves `app/emails/frontend/emails.html`
|
||||
- **Navigation**: Added "Email" link to top navbar
|
||||
- **Auto-refresh**: Polls every 30 seconds for new emails
|
||||
|
||||
### ✅ Design System Compliance
|
||||
|
||||
- **Nordic Top Colors**: Deep blue `#0f4c75` accent, clean white cards
|
||||
- **Dark Mode**: Full support with CSS variable theme switching
|
||||
- **Bootstrap 5**: Grid, buttons, badges, modals, tooltips
|
||||
- **Bootstrap Icons**: Consistent iconography throughout
|
||||
- **Responsive**: Mobile-friendly with collapsing columns (<768px hides analysis sidebar)
|
||||
|
||||
### ✅ Classification Badges
|
||||
|
||||
Color-coded pills for email types:
|
||||
- 📄 **Invoice**: Green (#d4edda)
|
||||
- 📦 **Order Confirmation**: Blue (#d1ecf1)
|
||||
- 🚚 **Freight Note**: Yellow (#fff3cd)
|
||||
- ⏰ **Time Confirmation**: Gray (#e2e3e5)
|
||||
- 📋 **Case Notification**: Light blue (#cce5ff)
|
||||
- ⚠️ **Bankruptcy**: Red (#f8d7da)
|
||||
- 🚫 **Spam**: Dark (#343a40)
|
||||
- 📧 **General**: Light gray (#e9ecef)
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Navigate to**: http://localhost:8001/emails
|
||||
2. **Browse emails** in left sidebar (30 emails loaded by default)
|
||||
3. **Click email** to view content in center pane
|
||||
4. **Edit classification** in right sidebar AI analysis panel
|
||||
5. **Use keyboard shortcuts** for power-user workflow
|
||||
6. **Bulk select** with checkboxes for batch operations
|
||||
7. **Search** with debounced search bar
|
||||
8. **Filter** by classification with pill buttons
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Files Created/Modified
|
||||
|
||||
1. **`app/emails/frontend/emails.html`** (1100+ lines)
|
||||
- Complete 3-column email UI with vanilla JavaScript
|
||||
- All CRUD operations, keyboard shortcuts, bulk actions
|
||||
- Responsive design with mobile fallbacks
|
||||
|
||||
2. **`app/emails/frontend/views.py`** (23 lines)
|
||||
- FastAPI router serving email template
|
||||
- `/emails` endpoint returns HTMLResponse
|
||||
|
||||
3. **`main.py`** (modified)
|
||||
- Added `emails_views` import
|
||||
- Registered email frontend router
|
||||
|
||||
4. **`app/shared/frontend/base.html`** (modified)
|
||||
- Added "Email" navigation link in top navbar
|
||||
|
||||
### State Management
|
||||
|
||||
JavaScript maintains:
|
||||
- `emails` - Array of email objects from API
|
||||
- `currentEmailId` - Currently selected email
|
||||
- `currentFilter` - Active classification filter ('all', 'invoice', etc.)
|
||||
- `selectedEmails` - Set of email IDs for bulk operations
|
||||
- `searchTimeout` - Debounce timer for search input
|
||||
- `autoRefreshInterval` - 30-second polling timer
|
||||
|
||||
### API Calls
|
||||
|
||||
- `GET /api/v1/emails?limit=100&classification={filter}&q={query}` - Load emails
|
||||
- `GET /api/v1/emails/{id}` - Load email detail
|
||||
- `PUT /api/v1/emails/{id}` - Update email (mark read, archive)
|
||||
- `PUT /api/v1/emails/{id}/classify` - Update classification
|
||||
- `POST /api/v1/emails/{id}/reprocess` - Reclassify with AI
|
||||
- `DELETE /api/v1/emails/{id}` - Soft delete
|
||||
- `POST /api/v1/emails/process` - Manual fetch new emails
|
||||
- `GET /api/v1/emails/stats/summary` - Load statistics
|
||||
- `GET /api/v1/email-rules` - Load email rules
|
||||
|
||||
### Responsive Behavior
|
||||
|
||||
- **Desktop (>1200px)**: Full 3-column layout
|
||||
- **Tablet (768-1200px)**: Narrower sidebars (280px, 260px)
|
||||
- **Mobile (<768px)**: Stacked layout, analysis sidebar hidden, smaller filter pills
|
||||
|
||||
## Next Steps (Future Enhancements)
|
||||
|
||||
1. **Attachment Preview**: Inline image preview, PDF viewer
|
||||
2. **Real-time Updates**: WebSocket for instant new email notifications
|
||||
3. **Advanced Search**: Full-text search with filters (date range, sender, has:attachment)
|
||||
4. **Email Compose**: Send replies or create new emails
|
||||
5. **Email Rules UI**: Full CRUD interface in modal (partially implemented)
|
||||
6. **Threading**: Group emails by conversation thread
|
||||
7. **Labels/Tags**: Custom user-defined labels beyond classification
|
||||
8. **Export**: Bulk export emails to CSV/JSON
|
||||
9. **Performance**: Virtual scrolling for 1000+ emails
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Email list renders with 30 emails
|
||||
- [x] Click email shows content in center pane
|
||||
- [x] AI analysis panel displays classification and confidence
|
||||
- [x] Filter pills update counts from stats endpoint
|
||||
- [x] Search bar filters emails (debounced)
|
||||
- [x] Keyboard shortcuts navigate emails (j/k)
|
||||
- [x] Bulk selection toolbar appears/disappears
|
||||
- [x] Archive/Delete/Reprocess actions work
|
||||
- [x] Classification dropdown updates email
|
||||
- [x] Auto-refresh polls every 30 seconds
|
||||
- [x] Responsive layout collapses on mobile
|
||||
- [x] Dark mode theme switching works
|
||||
- [x] Email Rules modal loads rules list
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Initial Load**: ~100 emails with 1 API call
|
||||
- **Search**: Debounced 300ms to reduce API calls
|
||||
- **Auto-refresh**: 30-second polling (configurable)
|
||||
- **Bulk Operations**: Uses `Promise.all()` for parallel execution
|
||||
- **Scrolling**: Native browser scroll (no virtual scrolling yet)
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- ✅ Chrome/Edge (latest)
|
||||
- ✅ Firefox (latest)
|
||||
- ✅ Safari (latest)
|
||||
- ✅ Mobile Safari/Chrome
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Keyboard navigation fully functional
|
||||
- Semantic HTML (nav, main, aside, article)
|
||||
- ARIA labels on buttons/icons
|
||||
- Focus indicators on interactive elements
|
||||
- Screen reader friendly (could be improved with aria-live regions)
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ Production Ready (Keyword Classification Mode)
|
||||
**Next Priority**: Fix Ollama AI classification or switch to OpenAI API
|
||||
2
main.py
2
main.py
@ -36,6 +36,7 @@ from app.devportal.backend import views as devportal_views
|
||||
from app.timetracking.backend import router as timetracking_api
|
||||
from app.timetracking.frontend import views as timetracking_views
|
||||
from app.emails.backend import router as emails_api
|
||||
from app.emails.frontend import views as emails_views
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@ -124,6 +125,7 @@ app.include_router(billing_views.router, tags=["Frontend"])
|
||||
app.include_router(settings_views.router, tags=["Frontend"])
|
||||
app.include_router(devportal_views.router, tags=["Frontend"])
|
||||
app.include_router(timetracking_views.router, tags=["Frontend"])
|
||||
app.include_router(emails_views.router, tags=["Frontend"])
|
||||
|
||||
# Serve static files (UI)
|
||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user