olety's picture
Initial deployment
028cd37 verified
"""
UI utility functions - merged from display.py and helpers.py
Making the UI component self-contained
"""
import os
import json
from datetime import datetime
from collections import defaultdict
from typing import List, Dict, Any, Tuple
import bleach
def sanitize_html(text: str, allow_tags: bool = False) -> str:
"""Sanitize HTML content to prevent XSS attacks"""
if not text:
return ""
if allow_tags:
# Allow basic formatting tags for email bodies
allowed_tags = ['p', 'br', 'b', 'i', 'u', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'pre', 'code']
allowed_attrs = {'a': ['href', 'title']}
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
else:
# For fields like subject, sender name - no HTML allowed
return bleach.clean(text, tags=[], attributes={}, strip=True)
def get_folder_counts(emails: List[Dict]) -> List[Tuple[str, int]]:
"""Extract unique folders and their counts from email data"""
folder_counts = defaultdict(int)
for email in emails:
if 'folder' in email and email['folder']:
folder_counts[email['folder']] += 1
else:
folder_counts['Inbox'] += 1
return sorted(folder_counts.items())
def get_folder_dropdown_choices(emails: List[Dict]) -> List[str]:
"""Get folder choices for dropdown in format 'Folder Name (Count)'"""
folder_counts = get_folder_counts(emails)
return [f"{folder_name} ({count})" for folder_name, count in folder_counts]
def extract_folder_name(folder_choice: str) -> str:
"""Extract folder name from dropdown choice format 'Folder Name (Count)'"""
if '(' in folder_choice:
return folder_choice.split(' (')[0]
return folder_choice
def format_date(date_string: str) -> str:
"""Format ISO date string to readable format"""
try:
dt = datetime.fromisoformat(date_string.replace('Z', '+00:00'))
now = datetime.now()
if dt.date() == now.date():
return dt.strftime("%H:%M")
elif dt.year == now.year:
return dt.strftime("%b %d")
else:
return dt.strftime("%b %d, %Y")
except (ValueError, AttributeError) as e:
return date_string
def sort_emails(emails: List[Dict], sort_option: str) -> List[Dict]:
"""Sort emails based on the selected sort option"""
if not emails:
return emails
def get_email_date(email):
try:
date_str = email.get('date', '')
if date_str:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
else:
return datetime(1970, 1, 1)
except (ValueError, AttributeError, KeyError) as e:
return datetime(1970, 1, 1)
if sort_option == "Newest First":
return sorted(emails, key=get_email_date, reverse=True)
elif sort_option == "Oldest First":
return sorted(emails, key=get_email_date, reverse=False)
else:
return emails
def create_email_html(emails: List[Dict], selected_folder: str = "Inbox", expanded_email_id: str = None, compact: bool = True) -> str:
"""Create HTML for email list"""
folder_counts = dict(get_folder_counts(emails))
clean_folder_name = extract_folder_name(selected_folder)
html = """
<style>
.email-container {
font-family: var(--font-family);
font-size: var(--font-size-sm);
}
.email-list {
border: var(--border-width) solid var(--color-gray-light);
border-radius: var(--border-radius);
overflow: hidden;
background: var(--color-white);
}
.email-row {
padding: var(--spacing-2) var(--spacing-3);
border-bottom: var(--border-width) solid var(--color-gray-light);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.email-row:hover {
background-color: var(--color-gray-lighter);
}
.email-row:last-child {
border-bottom: none;
}
.email-row-expanded {
background-color: var(--color-gray-lighter);
padding: var(--spacing-3);
}
.email-content {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.email-sender {
font-weight: var(--font-weight-medium);
color: var(--color-dark);
min-width: 120px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-sender-unread {
font-weight: var(--font-weight-semibold);
}
.email-subject {
color: var(--color-dark);
font-weight: var(--font-weight-medium);
}
.email-snippet {
color: var(--color-gray);
}
.email-meta {
color: var(--color-gray);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-date {
color: var(--color-gray);
font-size: var(--font-size-xs);
min-width: 50px;
text-align: right;
}
.email-label {
padding: 2px 6px;
border-radius: 12px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
margin-left: var(--spacing-1);
}
.label-unread {
background: var(--color-primary);
color: var(--color-white);
}
.label-important {
background: var(--color-danger);
color: var(--color-white);
}
.label-starred {
background: var(--color-warning);
color: var(--color-white);
}
.email-body {
margin-top: var(--spacing-3);
padding: var(--spacing-3);
background: var(--color-white);
border-radius: var(--border-radius-sm);
border-left: 3px solid var(--color-primary);
line-height: 1.5;
}
</style>
<div class="email-container">
<div class="email-list">
"""
for i, email in enumerate(emails):
# Email metadata with sanitization
email_id = email.get('id', f'email_{i}')
sender_name = sanitize_html(email.get('from_name', email.get('from_email', 'Unknown'))[:20])
subject = sanitize_html(email.get('subject', 'No Subject'))
snippet = sanitize_html(email.get('snippet', ''))
formatted_date = format_date(email.get('date', ''))
is_unread = "UNREAD" in email.get("labelIds", [])
is_expanded = expanded_email_id == email.get('id')
if compact and not is_expanded:
# Single line compact format
subject_preview = subject[:40] + "..." if len(subject) > 40 else subject
snippet_preview = snippet[:30] + "..." if len(snippet) > 30 else snippet
# Check for draft indicator
has_draft = "DRAFT_PENDING" in email.get("labelIds", []) or "DRAFT_PREVIEW" in email.get("labelIds", [])
draft_indicator = '<span class="draft-indicator" title="Draft reply available">📝</span> ' if has_draft else ""
# Update labels_html to use CSS classes
labels_html = ""
if email.get("labelIds"):
important_labels = ["UNREAD", "IMPORTANT", "STARRED"]
shown_labels = [l for l in email.get("labelIds", []) if l in important_labels][:2]
for label in shown_labels:
label_short = {"UNREAD": "New", "IMPORTANT": "!", "STARRED": "★"}.get(label, label[:3])
label_class = {"UNREAD": "label-unread", "IMPORTANT": "label-important", "STARRED": "label-starred"}.get(label, "")
labels_html += f'<span class="email-label {label_class}">{label_short}</span>'
sender_class = "email-sender email-sender-unread" if is_unread else "email-sender"
html += f"""
<div class="email-row" onclick="handleEmailAction('{email_id}')">
<div class="email-content">
<span class="{sender_class}">{sender_name}</span>
<span>•</span>
<span class="email-meta">
{draft_indicator}<span class="email-subject">{subject_preview}</span> - <span class="email-snippet">{snippet_preview}</span>
</span>
{labels_html}
<span class="email-date">{formatted_date}</span>
</div>
</div>
"""
else:
# Expanded view
# Update labels_html to use CSS classes
labels_html = ""
if email.get("labelIds"):
important_labels = ["UNREAD", "IMPORTANT", "STARRED"]
shown_labels = [l for l in email.get("labelIds", []) if l in important_labels][:2]
for label in shown_labels:
label_short = {"UNREAD": "New", "IMPORTANT": "!", "STARRED": "★"}.get(label, label[:3])
label_class = {"UNREAD": "label-unread", "IMPORTANT": "label-important", "STARRED": "label-starred"}.get(label, "")
labels_html += f'<span class="email-label {label_class}">{label_short}</span>'
# Sanitize expanded view data
from_name_full = sanitize_html(email.get('from_name', sender_name))
html += f"""
<div class="email-row email-row-expanded" onclick="handleEmailAction('{email_id}')">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--spacing-2);">
<span style="font-weight: var(--font-weight-semibold); color: var(--color-dark);">{from_name_full}</span>
<div style="display: flex; align-items: center; gap: var(--spacing-2);">
{labels_html}
<span class="email-date">{formatted_date}</span>
</div>
</div>
<div style="font-weight: var(--font-weight-medium); color: var(--color-dark); margin-bottom: var(--spacing-2);">{subject}</div>
"""
if 'body' in email:
# Sanitize body content with allowed tags
safe_body = sanitize_html(email['body'], allow_tags=True)
html += f"""
<div class="email-body">
<div style="color: var(--color-dark); white-space: pre-line;">{safe_body}</div>
</div>
"""
# Check for drafts to display
if "DRAFT_PENDING" in email.get("labelIds", []) or "DRAFT_PREVIEW" in email.get("labelIds", []):
# For now, show a simple draft preview
# In production, we'd fetch actual drafts from backend
html += f"""
<div class="draft-preview" style="margin-top: var(--spacing-4); padding: var(--spacing-3); background-color: #e3f2fd; border-radius: var(--radius-default); border-left: 4px solid #2196f3;">
<h4 style="margin: 0 0 var(--spacing-2) 0; color: #1976d2; font-size: var(--font-size-sm); display: flex; align-items: center;">
<span style="margin-right: var(--spacing-1);">📝</span> Draft Reply Ready
</h4>
<div style="font-size: var(--font-size-sm); color: var(--color-dark); font-style: italic;">
A personalized draft reply has been prepared for this email.
Click to view and edit the full draft.
</div>
<div style="margin-top: var(--spacing-2);">
<button style="font-size: var(--font-size-xs); padding: 4px 12px; background: #2196f3; color: white; border: none; border-radius: var(--radius-small); cursor: pointer;">
View Draft
</button>
</div>
</div>
"""
html += """
</div>
"""
html += """
</div>
</div>"""
return html
def filter_emails(folder: str, search_query: str = "", sort_option: str = "Newest First", emails_source: List[Dict] = None) -> List[Dict]:
"""Filter and sort emails based on folder, search query, and sort option"""
if emails_source is None:
return []
filtered = emails_source
clean_folder_name = extract_folder_name(folder)
# Filter by folder
folder_filtered = []
for email in filtered:
if 'folder' in email and email['folder'] == clean_folder_name:
folder_filtered.append(email)
elif not 'folder' in email and 'INBOX' == clean_folder_name:
folder_filtered.append(email)
filtered = folder_filtered
# Filter by search query
if search_query:
filtered = [email for email in filtered if
search_query.lower() in email.get('from_name', '').lower() or
search_query.lower() in email.get('from_email', '').lower() or
search_query.lower() in email.get('subject', '').lower() or
search_query.lower() in email.get('snippet', '').lower()]
# Sort emails
filtered = sort_emails(filtered, sort_option)
return filtered
def create_preview_banner(preview_rules_count: int = 0) -> str:
"""Create the preview mode banner HTML"""
if preview_rules_count == 0:
return ""
return f"""
<style>
#preview-banner {{
background: var(--color-warning);
color: var(--color-white);
padding: var(--spacing-2) var(--spacing-3);
margin-bottom: var(--spacing-2);
border-radius: var(--border-radius-sm);
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
box-shadow: var(--shadow-sm);
}}
#exit-preview-btn {{
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.4);
color: var(--color-white);
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm);
cursor: pointer;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
transition: background var(--transition-fast);
}}
#exit-preview-btn:hover {{
background: rgba(255, 255, 255, 0.3);
}}
</style>
<div id="preview-banner">
<div style="display: flex; align-items: center; gap: var(--spacing-2);">
<span>👁️</span>
<span>Preview Mode ({preview_rules_count} rule{'s' if preview_rules_count != 1 else ''})</span>
</div>
<button id="exit-preview-btn">
Exit Preview
</button>
</div>
"""
def create_sleek_rule_card(rule: Dict) -> str:
"""Create a single sleek two-row rule card with expandable details"""
rule_id = rule.get('rule_id', rule.get('id', ''))
print(f"DEBUG ui_utils: Creating card for rule '{rule.get('name')}' with ID: '{rule_id}'")
rule_name = sanitize_html(rule.get('name', 'Unnamed Rule'))
status = rule.get('status', 'pending')
# Determine button set based on status
buttons_html = ""
if status == 'pending':
buttons_html = f"""
<button class="rule-btn preview-btn" data-rule-id="{rule_id}">Show affected</button>
<button class="rule-btn archive-btn" data-rule-id="{rule_id}">Archive</button>
"""
elif status == 'preview':
buttons_html = f"""
<button class="rule-btn accept-btn" data-rule-id="{rule_id}">✓ Accept</button>
<button class="rule-btn reject-btn" data-rule-id="{rule_id}">✗ Reject</button>
"""
elif status == 'accepted':
buttons_html = f"""
<button class="rule-btn run-btn" data-rule-id="{rule_id}">▶ Run Rule</button>
<button class="rule-btn archive-btn" data-rule-id="{rule_id}">Archive</button>
"""
card_class = f"rule-card-v2 rule-status-{status}"
# Build details HTML
details_html = ""
# Add conditions
conditions = rule.get('conditions', [])
if conditions:
details_html += "<div style='margin-top: 12px;'><strong>Conditions:</strong><ul style='margin: 4px 0; padding-left: 20px;'>"
for condition in conditions:
field = condition.get('field', '')
operator = condition.get('operator', '')
value = condition.get('value', '')
# Convert operator to readable format
op_map = {'contains': 'contains', 'equals': 'equals', 'startswith': 'starts with', 'endswith': 'ends with'}
readable_op = op_map.get(operator, operator)
details_html += f"<li>{sanitize_html(field)} {readable_op} \"{sanitize_html(str(value))}\"</li>"
details_html += "</ul></div>"
# Add actions
actions = rule.get('actions', [])
if actions:
details_html += "<div style='margin-top: 8px;'><strong>Actions:</strong><ul style='margin: 4px 0; padding-left: 20px;'>"
for action in actions:
action_type = action.get('type', '')
if action_type == 'move':
folder = action.get('parameters', {}).get('folder', 'Unknown')
details_html += f"<li>Move to {sanitize_html(folder)} folder</li>"
elif action_type == 'label':
label = action.get('parameters', {}).get('label', 'Unknown')
details_html += f"<li>Add label: {sanitize_html(label)}</li>"
elif action_type == 'mark_as_read':
details_html += "<li>Mark as read</li>"
elif action_type == 'draft_reply':
details_html += "<li>Create draft reply</li>"
else:
details_html += f"<li>{sanitize_html(action_type)}</li>"
details_html += "</ul></div>"
# Add confidence if available
confidence = rule.get('confidence', 0)
if confidence > 0:
details_html += f"<div style='margin-top: 8px;'><strong>Confidence:</strong> {int(confidence * 100)}%</div>"
return f"""
<div id="rule-{rule_id}" class="{card_class}" onclick="toggleRuleDetails('{rule_id}')">
<div class="rule-row-1">
<span class="rule-expand-arrow" id="arrow-{rule_id}">▶</span>
<span class="rule-name">{rule_name}</span>
</div>
<div class="rule-row-2">
<div class="rule-actions">{buttons_html}</div>
</div>
<div class="rule-details" id="details-{rule_id}" style="display: none;">
{details_html}
</div>
</div>
"""
def create_interactive_rule_cards(pending_rules: List[Dict], compact: bool = True) -> str:
"""Create HTML for interactive rule cards display"""
if not pending_rules:
return """
<style>
.empty-rules {
font-family: var(--font-family);
padding: var(--spacing-4);
text-align: center;
color: var(--color-gray);
background: var(--color-gray-lighter);
border-radius: var(--border-radius);
margin: var(--spacing-2);
}
.empty-rules-icon {
font-size: 2rem;
margin-bottom: var(--spacing-2);
}
.empty-rules h4 {
margin: var(--spacing-2) 0;
color: var(--color-dark);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
}
.empty-rules p {
margin: 0;
font-size: var(--font-size-sm);
}
</style>
<div class="empty-rules">
<div class="empty-rules-icon">📋</div>
<h4>No Rules Yet</h4>
<p>Click "Analyze" to get started!</p>
</div>
"""
# Separate active and archived rules
active_rules = [r for r in pending_rules if r.get("status") != "rejected"]
archived_rules = [r for r in pending_rules if r.get("status") == "rejected"]
html = f"""
<style>
.rules-container {{
font-family: var(--font-family);
font-size: var(--font-size-sm);
}}
.rules-header {{
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-2);
}}
.rules-title {{
display: flex;
align-items: center;
gap: var(--spacing-2);
}}
.rules-title-text {{
font-weight: var(--font-weight-semibold);
color: var(--color-dark);
}}
.archive-toggle {{
font-size: var(--font-size-xs);
padding: var(--spacing-1) var(--spacing-2);
border: var(--border-width) solid var(--color-gray-light);
border-radius: var(--border-radius-sm);
background: var(--color-white);
cursor: pointer;
transition: all var(--transition-fast);
font-weight: var(--font-weight-medium);
}}
.archive-toggle:hover {{
background: var(--color-gray-lighter);
border-color: var(--color-gray);
}}
.rule-card {{
border: var(--border-width) solid var(--color-gray-light);
border-radius: var(--border-radius);
padding: var(--spacing-2) var(--spacing-3);
margin: var(--spacing-2) 0;
background: var(--color-white);
transition: all var(--transition-fast);
box-shadow: var(--shadow-sm);
}}
.rule-card:hover {{
box-shadow: var(--shadow);
transform: translateY(-1px);
}}
.rule-card-accepted {{
background: #d4edda;
border-color: var(--color-success);
}}
.rule-card-preview {{
background: #fff3cd;
border-color: var(--color-warning);
}}
.rule-content {{
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
}}
.rule-info {{
flex: 1;
min-width: 0;
cursor: pointer;
}}
.rule-name-row {{
display: flex;
align-items: center;
gap: var(--spacing-2);
}}
.rule-toggle-arrow {{
cursor: pointer;
font-size: var(--font-size-sm);
width: 15px;
text-align: center;
color: var(--color-gray);
transition: transform var(--transition-fast);
}}
.rule-name {{
font-weight: var(--font-weight-semibold);
color: var(--color-dark);
font-size: var(--font-size-sm);
}}
.rule-summary {{
color: var(--color-gray);
font-size: var(--font-size-xs);
margin-top: var(--spacing-1);
padding-left: 23px;
}}
.rule-actions {{
display: flex;
gap: var(--spacing-1);
align-items: center;
}}
.rule-btn {{
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-sm);
cursor: pointer;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
transition: all var(--transition-fast);
border: var(--border-width) solid transparent;
}}
.rule-btn:hover {{
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}}
.preview-btn {{
border-color: var(--color-primary);
background: var(--color-white);
color: var(--color-primary);
}}
.preview-btn:hover {{
background: var(--color-primary);
color: var(--color-white);
}}
.accept-btn {{
background: var(--color-success);
color: var(--color-white);
}}
.accept-btn:hover {{
background: #218838;
}}
.reject-btn {{
background: var(--color-danger);
color: var(--color-white);
}}
.reject-btn:hover {{
background: #c82333;
}}
.exit-preview-card-btn {{
border-color: var(--color-warning);
background: var(--color-white);
color: var(--color-warning);
}}
.exit-preview-card-btn:hover {{
background: var(--color-warning);
color: var(--color-white);
}}
.rule-details {{
display: none;
margin-top: var(--spacing-3);
padding: var(--spacing-3);
background: rgba(0,0,0,0.02);
border-radius: var(--border-radius-sm);
font-size: var(--font-size-xs);
}}
.rule-details-expanded {{
display: block;
}}
.archived-section {{
margin-top: var(--spacing-4);
padding-top: var(--spacing-3);
border-top: var(--border-width) solid var(--color-gray-light);
}}
.archived-rule {{
border: var(--border-width) solid var(--color-gray-light);
border-radius: var(--border-radius);
padding: var(--spacing-2) var(--spacing-3);
margin: var(--spacing-2) 0;
background: var(--color-gray-lighter);
opacity: 0.7;
}}
.reactivate-btn {{
padding: var(--spacing-1) var(--spacing-2);
border: var(--border-width) solid var(--color-success);
background: var(--color-white);
color: var(--color-success);
border-radius: var(--border-radius-sm);
cursor: pointer;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
transition: all var(--transition-fast);
}}
.reactivate-btn:hover {{
background: var(--color-success);
color: var(--color-white);
}}
</style>
<div class="rules-container">
<!-- Active Rules -->
<div style="margin-bottom: var(--spacing-4);">
<div class="rules-header">
<div class="rules-title">
<span style="font-size: 1rem;">📋</span>
<span class="rules-title-text">Active Rules ({len(active_rules)})</span>
</div>
{f'<button onclick="toggleArchived()" class="archive-toggle">{"Hide" if archived_rules else "Show"} Archived ▼</button>' if archived_rules else ''}
</div>
"""
# Active rules - using new sleek cards
for rule in active_rules:
if rule.get("status") == "rejected":
continue
html += create_sleek_rule_card(rule)
html += """
</div>
"""
# Archived rules section (if any)
if archived_rules:
html += f"""
<div id="archived-section" style="margin-top: 1rem; display: none;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="font-size: 1rem;">🗄️</span>
<span style="font-weight: 600; color: #6c757d;">Archived Rules ({len(archived_rules)})</span>
</div>
"""
for rule in archived_rules:
rule_id = rule.get('rule_id', rule.get('id', ''))
archived_rule_name = sanitize_html(rule.get('name', 'Unnamed Rule'))
html += f"""
<div class="archived-rule">
<div style="display: flex; align-items: center; justify-content: space-between;">
<span style="color: var(--color-gray); font-size: var(--font-size-sm);">{archived_rule_name}</span>
<button class="reactivate-btn" data-rule-id="{rule_id}">Reactivate</button>
</div>
</div>
"""
html += """
</div>
"""
html += """
</div>
<script>
function toggleArchived() {
const section = document.getElementById('archived-section');
if (section) {
section.style.display = section.style.display === 'none' ? 'block' : 'none';
}
}
</script>
"""
return html
def get_preview_rules_count(pending_rules: List[Dict[str, Any]]) -> int:
"""Get the number of rules currently in preview status"""
return len([rule for rule in pending_rules if rule.get("status") == "preview"])