feat(email): add functionality to send emails with attachments from case tab
This commit is contained in:
parent
1d7107bff0
commit
695854a272
38
.github/skills/gui-starter/SKILL.md
vendored
Normal file
38
.github/skills/gui-starter/SKILL.md
vendored
Normal 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.
|
||||
@ -105,6 +105,7 @@ class Settings(BaseSettings):
|
||||
EMAIL_AI_ENABLED: bool = False
|
||||
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled)
|
||||
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_PROCESS_INTERVAL_MINUTES: int = 5
|
||||
EMAIL_WORKFLOWS_ENABLED: bool = True
|
||||
|
||||
@ -142,10 +142,115 @@ class ProcessingStats(BaseModel):
|
||||
fetched: int = 0
|
||||
saved: int = 0
|
||||
classified: int = 0
|
||||
awaiting_user_action: int = 0
|
||||
rules_matched: 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
|
||||
@router.get("/emails", response_model=List[EmailListItem])
|
||||
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))
|
||||
|
||||
|
||||
@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")
|
||||
async def extract_vendor_suggestion(email_id: int):
|
||||
"""
|
||||
@ -1206,6 +1506,7 @@ async def get_processing_stats():
|
||||
SELECT
|
||||
COUNT(*) as total_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 = 'error') as error_emails,
|
||||
COUNT(*) FILTER (WHERE has_attachments = true) as with_attachments,
|
||||
@ -1225,6 +1526,7 @@ async def get_processing_stats():
|
||||
return {
|
||||
"total_emails": 0,
|
||||
"new_emails": 0,
|
||||
"awaiting_user_action": 0,
|
||||
"processed_emails": 0,
|
||||
"error_emails": 0,
|
||||
"with_attachments": 0,
|
||||
@ -1494,6 +1796,7 @@ async def get_email_stats():
|
||||
SELECT
|
||||
COUNT(*) as total_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 classification = 'invoice' THEN 1 END) as invoices,
|
||||
COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations,
|
||||
|
||||
@ -436,6 +436,45 @@
|
||||
color: var(--accent);
|
||||
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 */
|
||||
@media (max-width: 1200px) {
|
||||
@ -871,6 +910,9 @@
|
||||
<button class="filter-pill" data-filter="processed" onclick="setFilter('processed')">
|
||||
Behandlet <span class="count" id="countProcessed">0</span>
|
||||
</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')">
|
||||
Sag <span class="count" id="countCase">0</span>
|
||||
</button>
|
||||
@ -1312,6 +1354,7 @@ let emails = [];
|
||||
let selectedEmails = new Set();
|
||||
let emailSearchTimeout = null;
|
||||
let autoRefreshInterval = null;
|
||||
let sagAssignmentOptions = { users: [], groups: [] };
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@ -1322,6 +1365,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadStats();
|
||||
setupEventListeners();
|
||||
setupKeyboardShortcuts();
|
||||
preloadSagAssignmentOptions();
|
||||
startAutoRefresh();
|
||||
});
|
||||
|
||||
@ -1431,13 +1475,12 @@ async function loadEmails(searchQuery = '') {
|
||||
|
||||
// Handle special filters
|
||||
if (currentFilter === 'active') {
|
||||
// Show only new, error, or flagged (pending review) emails
|
||||
// If searching, ignore status filter to allow global search
|
||||
if (!searchQuery) {
|
||||
url += '&status=new';
|
||||
}
|
||||
// Active queue includes both new and awaiting manual handling.
|
||||
// We fetch list data and filter client-side because API status filter is single-value.
|
||||
} else if (currentFilter === 'processed') {
|
||||
url += '&status=processed';
|
||||
} else if (currentFilter === 'awaiting_user_action') {
|
||||
url += '&status=awaiting_user_action';
|
||||
} else if (currentFilter !== 'all') {
|
||||
// Classification filter
|
||||
url += `&classification=${currentFilter}`;
|
||||
@ -1455,6 +1498,11 @@ async function loadEmails(searchQuery = '') {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
renderEmailList(emails);
|
||||
@ -1757,52 +1805,131 @@ function renderEmailAnalysis(email) {
|
||||
console.error('aiAnalysisTab element not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const classification = email.classification || 'general';
|
||||
const confidence = email.confidence_score || 0;
|
||||
|
||||
// Opdater kun AI Analysis tab indholdet - ikke hele sidebar
|
||||
aiAnalysisTab.innerHTML = `
|
||||
${!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>
|
||||
`}
|
||||
const primaryType = suggestPrimaryType(email);
|
||||
const secondaryLabel = suggestSecondaryLabel(email);
|
||||
const selectedCustomerName = email.customer_name || '';
|
||||
|
||||
${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">
|
||||
<h6><i class="bi bi-lightning-charge-fill me-2"></i>Hurtig Action</h6>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
${getClassificationActions(email)}
|
||||
<h6><i class="bi bi-stars me-2"></i>System Forslag</h6>
|
||||
<div class="quick-action-row mb-3">
|
||||
<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 class="analysis-card">
|
||||
<h6><i class="bi bi-robot me-2"></i>AI Klassificering</h6>
|
||||
|
||||
<div class="confidence-meter">
|
||||
<div class="confidence-bar">
|
||||
<div class="confidence-fill" style="width: ${confidence * 100}%"></div>
|
||||
@ -1812,7 +1939,7 @@ function renderEmailAnalysis(email) {
|
||||
<span><strong>${Math.round(confidence * 100)}%</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<select class="classification-select" id="classificationSelect" onchange="updateClassification()">
|
||||
<option value="invoice" ${classification === 'invoice' ? 'selected' : ''}>📄 Faktura</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="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>
|
||||
</select>
|
||||
|
||||
|
||||
<button class="btn btn-sm btn-primary w-100" onclick="saveClassification()">
|
||||
<i class="bi bi-check-lg me-2"></i>Gem Klassificering
|
||||
</button>
|
||||
</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() {
|
||||
@ -1914,12 +2182,14 @@ async function loadStats() {
|
||||
const response = await fetch('/api/v1/emails/stats/summary');
|
||||
const stats = await response.json();
|
||||
|
||||
// Calculate active emails (new + error + flagged)
|
||||
const activeCount = stats.new_emails || 0;
|
||||
const newCount = stats.new_emails || 0;
|
||||
const awaitingCount = stats.awaiting_user_action || 0;
|
||||
const activeCount = newCount + awaitingCount;
|
||||
|
||||
document.getElementById('countActive').textContent = activeCount;
|
||||
document.getElementById('countAll').textContent = stats.total_emails || 0;
|
||||
document.getElementById('countProcessed').textContent = stats.processed_emails || 0;
|
||||
document.getElementById('countAwaiting').textContent = awaitingCount;
|
||||
document.getElementById('countInvoice').textContent = stats.invoices || 0;
|
||||
document.getElementById('countOrder').textContent = 0;
|
||||
document.getElementById('countFreight').textContent = 0;
|
||||
@ -2272,11 +2542,20 @@ async function createTimeEntry(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) {
|
||||
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 ────────────────────────────────────────────────
|
||||
@ -2688,6 +2967,10 @@ function formatClassification(classification) {
|
||||
function getStatusBadge(email) {
|
||||
const status = email.status || 'new';
|
||||
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') {
|
||||
return '<span class="badge bg-success badge-sm ms-1"><i class="bi bi-check-circle me-1"></i>Behandlet</span>';
|
||||
|
||||
@ -49,6 +49,7 @@ class EmailProcessorService:
|
||||
'fetched': 0,
|
||||
'saved': 0,
|
||||
'classified': 0,
|
||||
'awaiting_user_action': 0,
|
||||
'rules_matched': 0,
|
||||
'errors': 0
|
||||
}
|
||||
@ -86,6 +87,8 @@ class EmailProcessorService:
|
||||
|
||||
if result.get('classified'):
|
||||
stats['classified'] += 1
|
||||
if result.get('awaiting_user_action'):
|
||||
stats['awaiting_user_action'] += 1
|
||||
if result.get('rules_matched'):
|
||||
stats['rules_matched'] += 1
|
||||
|
||||
@ -109,6 +112,7 @@ class EmailProcessorService:
|
||||
email_id = email_data.get('id')
|
||||
stats = {
|
||||
'classified': False,
|
||||
'awaiting_user_action': False,
|
||||
'workflows_executed': 0,
|
||||
'rules_matched': False
|
||||
}
|
||||
@ -123,6 +127,22 @@ class EmailProcessorService:
|
||||
if settings.EMAIL_AUTO_CLASSIFY:
|
||||
await self._classify_and_update(email_data)
|
||||
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
|
||||
workflow_processed = False
|
||||
@ -172,6 +192,25 @@ class EmailProcessorService:
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in process_single_email for {email_id}: {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):
|
||||
"""Classify email and update database"""
|
||||
|
||||
37
backups/email_feature/README.md
Normal file
37
backups/email_feature/README.md
Normal 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.
|
||||
BIN
backups/email_feature/email_feature_backup_20260317_214413.zip
Normal file
BIN
backups/email_feature/email_feature_backup_20260317_214413.zip
Normal file
Binary file not shown.
10
migrations/145_sag_start_date.sql
Normal file
10
migrations/145_sag_start_date.sql
Normal 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).';
|
||||
Loading…
Reference in New Issue
Block a user