mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-27 06:46:30 -05:00
Resolved conflicts in ~47 files. Kept local customizations and integrated upstream security fixes, channels streaming, async migration, and new features. Backend - RAG / vector / audio / video / storage - retrieval.py, knowledge.py: kept two-namespace deletion pattern, expandable tree, Drive sync hooks, get_namespace_for_collection helper - pinecone.py + retrieval/vector/utils.py: kept namespace isolation, retry logic, PINECONE_DIMENSION PersistentConfig - retrieval/utils.py + loaders/main.py: kept RobustCSVLoader, transcript segments; took upstream Excel/PPTX loaders - audio.py: preserved results[idx] chunk ordering, HTTPException re-raise, audio/* + video/* MIME support - storage/provider.py: kept caching + signed URL changes Backend - core - main.py: combined local schedulers (temp file cleanup, Gmail sync, Drive sync) with upstream scheduler_worker_loop; kept gmail.router - config.py: kept Alembic multi-head cleanup + upgrade-to-heads, expanded GOOGLE_OAUTH_SCOPE (Gmail + Drive readonly), merged GOOGLE_OAUTH_AUTHORIZE_PARAMS, kept local RAG defaults + VIDEO_AUDIO_CHUNKING_STRATEGY - env.py: kept local WEBUI_NAME branding (no '(Open WebUI)' suffix) - middleware.py: combined UserUsages.record_usage with upstream outlet_filter_handler - oauth.py: awaited update_session_by_id, kept verbose token-refresh errors and trigger_gmail_sync_if_needed - pdf_generator.py: kept ReportLab implementation with custom fonts - task.py: made prompt_improvement_template async-aware - routers/tasks.py: awaited prompt_improvement_template - utils/models.py: kept missing_function_ids tracking - utils/misc.py: kept broader audio/video MIME support Backend - models - users.py: added Float import, kept UserSpendLimitForm, gmail_sync default, UserUsages cleanup; took upstream profile_image_url validator and async delete pattern - chats.py: kept JSONB indexes, archived/pinned, custom indexes, PG17 JSON_TABLE search optimization; took upstream async migration Backend - routers (upstream security fixes + local hooks) - channels.py: took upstream get_filtered_models + CHAT_COMPLETION_HANDLER; kept process_message_knowledge for RAG context injection - chats.py, files.py, users.py, memories.py, openai.py, auths.py: took upstream mass-assignment, SSRF, ownership, image URL validation fixes; kept spend-limit and external user ID hooks Alembic migrations - Renamed upstream e1f2a3b4c5d6_add_is_pinned_to_note.py revision id to e2f3a4b5c6d7 to resolve collision with our merge_all_heads migration - Added f7e8d9c0b1a2_merge_post_v0_9_5.py merging e1f2a3b4c5d6 + a0b1c2d3e4f5 into a single head Frontend (Svelte) - Chat.svelte: kept previousChatIdProp dedup + messageQueue for in-flight messages - CitationModal.svelte: kept WEBUI_BASE_URL import alongside upstream config + injectCsp - ContentRenderer.svelte: kept csv/json artifact detection alongside upstream tool-call/reasoning rendering - MarkdownTokens.svelte: kept tableToCSV alongside new GROUPABLE_DETAIL helpers - Placeholder.svelte: kept mobile-friendly model selection layout - channel/Messages/Message.svelte: re-inserted Knowledge Sources display; took upstream timestamp formatting - channel/MessageInput/InputMenu.svelte: removed bits-ui, kept knowledge attach tabs - workspace/Knowledge/KnowledgeBase/AddContentMenu.svelte: kept Connect Google Drive entry, normalized button styling - layout/Navbar/Menu.svelte + layout/Sidebar/ChatMenu.svelte: kept ReportLab backend PDF + Word doc export, layered upstream client-side PDF as alternative Translations + assets - en-US/translation.json: merged new upstream keys, kept local PPTX, Gmail sync, video chapters, spend-limit strings - favicons left as-is (kept ours) Dependencies - requirements.txt + pyproject.toml: harmonized redis 7.4.0, kept unstructured[all-docs] extra, took upstream pydub 0.25.1, ddgs 9.11.4 Dockerfile - Adopted upstream ENV UV_LINK_MODE=copy for QEMU arm64 cross-builds; kept local CUDA + model pre-download blocks Version bump - package.json + pyproject.toml -> 0.9.5 - CHANGELOG.md: added Fork Merge Summary section Made-with: Cursor
497 lines
17 KiB
Python
497 lines
17 KiB
Python
"""
|
|
Gmail Email Processor
|
|
|
|
This module provides email parsing and metadata extraction from Gmail API responses.
|
|
It's designed to work standalone without external dependencies (no Pinecone, no embeddings).
|
|
|
|
Purpose: Transform Gmail API JSON into clean, structured data ready for indexing.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import logging
|
|
import re
|
|
from datetime import datetime
|
|
from email.utils import parsedate_to_datetime
|
|
from typing import Dict, List, Optional, Tuple
|
|
from html import unescape
|
|
|
|
from open_webui.utils.email_cleaner import EmailCleaner
|
|
|
|
# Set up logger with INFO level for visibility
|
|
logger = logging.getLogger(__name__)
|
|
logger.setLevel(logging.INFO)
|
|
|
|
|
|
class GmailProcessor:
|
|
"""
|
|
Processes Gmail API responses into structured email data.
|
|
|
|
This is a pure data transformation class - no external API calls,
|
|
no database operations, just parsing and cleaning.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the processor"""
|
|
pass
|
|
|
|
def parse_email(self, email_data: dict, user_id: str) -> Dict:
|
|
"""
|
|
Parse a Gmail API message response into structured data.
|
|
|
|
Args:
|
|
email_data: Full Gmail API message response (format='full')
|
|
user_id: User ID for metadata isolation
|
|
|
|
Returns:
|
|
Dict with parsed email data and metadata
|
|
|
|
Example:
|
|
>>> processor = GmailProcessor()
|
|
>>> result = processor.parse_email(gmail_api_response, "user_123")
|
|
>>> print(result["subject"])
|
|
"Q4 Budget Discussion"
|
|
"""
|
|
|
|
try:
|
|
# Extract basic identifiers
|
|
email_id = email_data.get("id", "")
|
|
thread_id = email_data.get("threadId", "")
|
|
|
|
if not email_id:
|
|
raise ValueError("Email data missing 'id' field")
|
|
|
|
# Extract headers
|
|
headers_dict = self._extract_headers(email_data)
|
|
|
|
# Extract core email fields
|
|
subject = headers_dict.get("Subject", "(No Subject)")
|
|
from_addr = headers_dict.get("From", "")
|
|
to_addr = headers_dict.get("To", "")
|
|
cc_addr = headers_dict.get("Cc", "")
|
|
date_str = headers_dict.get("Date", "")
|
|
|
|
# Parse date
|
|
date_timestamp, date_readable = self._parse_date(date_str, email_data.get("internalDate"))
|
|
|
|
# Extract email body
|
|
payload = email_data.get("payload", {})
|
|
body_raw = self._extract_body(payload)
|
|
snippet = email_data.get("snippet", "")
|
|
|
|
# Clean email body with advanced cleaning
|
|
body_full_clean, body_original_only = EmailCleaner.clean_email_body(body_raw)
|
|
|
|
# Use original message (no quotes/signatures) for primary content
|
|
body_clean = body_original_only if body_original_only else body_full_clean
|
|
|
|
# Extract labels
|
|
labels = email_data.get("labelIds", [])
|
|
|
|
# Extract attachment information
|
|
attachments = self._get_attachments(payload)
|
|
has_attachments = len(attachments) > 0
|
|
|
|
# Parse email addresses
|
|
from_name, from_email = EmailCleaner.parse_email_address(from_addr)
|
|
to_name, to_email = EmailCleaner.parse_email_address(to_addr)
|
|
|
|
# Detect if this is a reply
|
|
is_reply = "RE:" in subject.upper() or "Re:" in subject or any(label == "SENT" for label in labels)
|
|
|
|
# Create document text (what will be embedded) - cleaner format
|
|
document_text = self._create_document_text(
|
|
subject=subject,
|
|
from_name=from_name,
|
|
from_email=from_email,
|
|
to_email=to_email,
|
|
date_readable=date_readable,
|
|
body=body_clean,
|
|
snippet=snippet,
|
|
)
|
|
|
|
# Build metadata (improved structure for better search)
|
|
metadata = {
|
|
# Core identification
|
|
"type": "email",
|
|
"email_id": email_id,
|
|
"thread_id": thread_id,
|
|
"user_id": user_id,
|
|
# Email fields (structured)
|
|
"subject": subject,
|
|
"from_name": from_name,
|
|
"from_email": from_email,
|
|
"from_raw": from_addr, # Keep original for reference
|
|
"to_name": to_name,
|
|
"to_email": to_email,
|
|
"to_raw": to_addr,
|
|
"cc": cc_addr,
|
|
"date": date_readable,
|
|
"date_timestamp": date_timestamp,
|
|
"labels": labels, # Keep as array, not comma-separated string
|
|
# Content metadata
|
|
"has_attachments": has_attachments,
|
|
"attachment_count": len(attachments),
|
|
"is_reply": is_reply,
|
|
"word_count": len(body_clean.split()),
|
|
"body_length": len(body_clean),
|
|
"original_body_length": len(body_raw),
|
|
# Cleaned text fields
|
|
"body_full_clean": body_full_clean, # Full email with cleaned quotes
|
|
"body_original": body_clean, # Just the original message
|
|
# Categorization
|
|
"source": "gmail",
|
|
"doc_type": "email",
|
|
# Hash for deduplication
|
|
"hash": hashlib.sha256(f"{email_id}{user_id}".encode()).hexdigest(),
|
|
}
|
|
|
|
return {
|
|
"email_id": email_id,
|
|
"thread_id": thread_id,
|
|
"document_text": document_text,
|
|
"metadata": metadata,
|
|
"raw_body": body_raw,
|
|
"cleaned_body": body_clean,
|
|
"snippet": snippet,
|
|
"attachments": attachments, # List of attachment metadata
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing email {email_data.get('id', 'unknown')}: {e}")
|
|
raise
|
|
|
|
def _extract_headers(self, email_data: dict) -> Dict[str, str]:
|
|
"""Extract email headers from Gmail API response"""
|
|
|
|
payload = email_data.get("payload", {})
|
|
headers_list = payload.get("headers", [])
|
|
|
|
# Convert list of {name, value} to dict
|
|
headers_dict = {}
|
|
for header in headers_list:
|
|
name = header.get("name", "")
|
|
value = header.get("value", "")
|
|
headers_dict[name] = value
|
|
|
|
return headers_dict
|
|
|
|
def _parse_date(self, date_str: str, internal_date: Optional[str] = None) -> Tuple[int, str]:
|
|
"""
|
|
Parse email date into timestamp and readable format.
|
|
|
|
Args:
|
|
date_str: Date header from email (e.g., "Mon, 15 Nov 2020 14:30:00 +0000")
|
|
internal_date: Gmail internal date (milliseconds since epoch)
|
|
|
|
Returns:
|
|
Tuple of (unix_timestamp, readable_date_string)
|
|
"""
|
|
|
|
# Try to parse the Date header
|
|
try:
|
|
date_obj = parsedate_to_datetime(date_str)
|
|
timestamp = int(date_obj.timestamp())
|
|
readable = date_obj.strftime("%Y-%m-%d %H:%M:%S")
|
|
return timestamp, readable
|
|
except Exception as e:
|
|
logger.debug(f"Could not parse date '{date_str}': {e}")
|
|
|
|
# Fallback to internal date
|
|
if internal_date:
|
|
try:
|
|
timestamp = int(internal_date) // 1000 # Convert ms to seconds
|
|
date_obj = datetime.fromtimestamp(timestamp)
|
|
readable = date_obj.strftime("%Y-%m-%d %H:%M:%S")
|
|
return timestamp, readable
|
|
except Exception as e:
|
|
logger.debug(f"Could not parse internal date '{internal_date}': {e}")
|
|
|
|
# Final fallback: current time
|
|
now = int(datetime.now().timestamp())
|
|
return now, "Unknown Date"
|
|
|
|
def _extract_body(self, payload: dict) -> str:
|
|
"""
|
|
Recursively extract email body from Gmail API payload.
|
|
|
|
Handles:
|
|
- Plain text emails
|
|
- HTML emails (strips tags)
|
|
- Multipart emails (multipart/alternative, multipart/mixed)
|
|
- Nested MIME structures
|
|
|
|
Args:
|
|
payload: Gmail API message payload
|
|
|
|
Returns:
|
|
Extracted email body as plain text
|
|
"""
|
|
|
|
# Handle multipart emails (most common)
|
|
if "parts" in payload:
|
|
return self._extract_from_parts(payload["parts"])
|
|
|
|
# Handle single-part emails
|
|
elif "body" in payload and "data" in payload["body"]:
|
|
mime_type = payload.get("mimeType", "")
|
|
data = payload["body"]["data"]
|
|
return self._decode_body_data(data, mime_type)
|
|
|
|
return ""
|
|
|
|
def _extract_from_parts(self, parts: list) -> str:
|
|
"""
|
|
Extract body from multipart email structure.
|
|
|
|
Priority:
|
|
1. text/plain (preferred)
|
|
2. text/html (strip tags)
|
|
3. Nested parts (recurse)
|
|
"""
|
|
|
|
# First pass: look for text/plain
|
|
for part in parts:
|
|
mime_type = part.get("mimeType", "")
|
|
|
|
if mime_type == "text/plain":
|
|
data = part.get("body", {}).get("data", "")
|
|
if data:
|
|
body = self._decode_body_data(data, mime_type)
|
|
if body:
|
|
return body
|
|
|
|
# Second pass: look for text/html
|
|
for part in parts:
|
|
mime_type = part.get("mimeType", "")
|
|
|
|
if mime_type == "text/html":
|
|
data = part.get("body", {}).get("data", "")
|
|
if data:
|
|
body = self._decode_body_data(data, mime_type)
|
|
if body:
|
|
return body
|
|
|
|
# Third pass: recurse into nested parts
|
|
for part in parts:
|
|
if "parts" in part:
|
|
body = self._extract_from_parts(part["parts"])
|
|
if body:
|
|
return body
|
|
|
|
return ""
|
|
|
|
def _decode_body_data(self, data: str, mime_type: str) -> str:
|
|
"""
|
|
Decode base64-encoded email body data.
|
|
|
|
Args:
|
|
data: Base64-encoded body data
|
|
mime_type: MIME type (text/plain or text/html)
|
|
|
|
Returns:
|
|
Decoded and cleaned text
|
|
"""
|
|
|
|
if not data:
|
|
return ""
|
|
|
|
try:
|
|
# Gmail API uses URL-safe base64 encoding
|
|
decoded_bytes = base64.urlsafe_b64decode(data)
|
|
decoded_text = decoded_bytes.decode("utf-8", errors="ignore")
|
|
|
|
# Clean based on MIME type
|
|
if mime_type == "text/html":
|
|
decoded_text = self._strip_html_tags(decoded_text)
|
|
|
|
return decoded_text.strip()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error decoding body data: {e}")
|
|
return ""
|
|
|
|
def _strip_html_tags(self, html_text: str) -> str:
|
|
"""
|
|
Strip HTML tags and extract plain text.
|
|
|
|
Comprehensive implementation handles:
|
|
- DOCTYPE and XML declarations
|
|
- Script and style blocks
|
|
- HTML comments and CDATA
|
|
- MSO (Microsoft Office) conditional comments
|
|
- Inline styles
|
|
- All HTML tags
|
|
"""
|
|
|
|
if not html_text:
|
|
return ""
|
|
|
|
text = html_text
|
|
|
|
# Remove DOCTYPE declarations (<!DOCTYPE...>)
|
|
text = re.sub(r"<![^>]*>", "", text, flags=re.IGNORECASE)
|
|
# Remove XML declarations (<?xml...?>)
|
|
text = re.sub(r"<\?[^>]*\?>", "", text, flags=re.IGNORECASE)
|
|
# Remove CDATA sections
|
|
text = re.sub(r"<!\[CDATA\[.*?\]\]>", "", text, flags=re.DOTALL)
|
|
# Remove HTML comments (<!--...-->)
|
|
text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
|
# Remove MSO (Microsoft Office) conditional comments
|
|
text = re.sub(r"\[if[^\]]*\].*?\[endif\]", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
|
|
# Remove script and style tags with their content
|
|
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
|
|
# Remove inline styles (before removing tags to prevent garbage)
|
|
text = re.sub(r'\s*style\s*=\s*["\'][^"\']*["\']', "", text, flags=re.IGNORECASE)
|
|
|
|
# Replace common block elements with newlines
|
|
text = re.sub(r"</(p|div|h[1-6]|li|tr)>", "\n", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
|
|
|
|
# Remove all remaining HTML tags
|
|
text = re.sub(r"<[^>]+>", "", text)
|
|
|
|
# Decode HTML entities
|
|
text = unescape(text)
|
|
|
|
# Clean up whitespace
|
|
text = re.sub(r"\n\s*\n", "\n\n", text)
|
|
text = re.sub(r" +", " ", text)
|
|
|
|
return text.strip()
|
|
|
|
def _has_attachments(self, payload: dict) -> bool:
|
|
"""Check if email has attachments (returns boolean only)."""
|
|
attachments = self._get_attachments(payload)
|
|
return len(attachments) > 0
|
|
|
|
def _get_attachments(self, payload: dict, attachments: list = None) -> list:
|
|
"""
|
|
Extract attachment metadata from email payload.
|
|
|
|
Args:
|
|
payload: Gmail API message payload
|
|
attachments: List to accumulate attachments (for recursion)
|
|
|
|
Returns:
|
|
List of attachment dicts with: filename, mimeType, size, attachmentId
|
|
"""
|
|
if attachments is None:
|
|
attachments = []
|
|
|
|
if "parts" in payload:
|
|
for part in payload["parts"]:
|
|
filename = part.get("filename", "")
|
|
|
|
# Part has a filename and body.attachmentId - it's an attachment
|
|
if filename and part.get("body", {}).get("attachmentId"):
|
|
attachment_info = {
|
|
"filename": filename,
|
|
"mimeType": part.get("mimeType", "application/octet-stream"),
|
|
"size": part.get("body", {}).get("size", 0),
|
|
"attachmentId": part.get("body", {}).get("attachmentId"),
|
|
}
|
|
attachments.append(attachment_info)
|
|
|
|
# Recursively check nested parts
|
|
if "parts" in part:
|
|
self._get_attachments(part, attachments)
|
|
|
|
return attachments
|
|
|
|
def _create_document_text(
|
|
self,
|
|
subject: str,
|
|
from_name: str,
|
|
from_email: str,
|
|
to_email: str,
|
|
date_readable: str,
|
|
body: str,
|
|
snippet: str,
|
|
) -> str:
|
|
"""
|
|
Create clean document text for embedding.
|
|
|
|
Simple, clean format without headers (better for semantic search).
|
|
"""
|
|
|
|
# Use cleaned body if available, otherwise snippet
|
|
content = body if body else snippet
|
|
|
|
# Build clean document - just subject and content (no "Email Subject:" labels)
|
|
if subject and subject != "(No Subject)":
|
|
document_text = f"{subject}\n\n{content}"
|
|
else:
|
|
document_text = content
|
|
|
|
return document_text.strip()
|
|
|
|
|
|
# ============================================================================
|
|
# HELPER FUNCTIONS FOR TESTING
|
|
# ============================================================================
|
|
|
|
|
|
def create_sample_gmail_response() -> dict:
|
|
"""
|
|
Create a sample Gmail API response for testing.
|
|
|
|
This is useful for unit tests and development.
|
|
"""
|
|
|
|
return {
|
|
"id": "msg_18c2a3b4d5e6f7g8",
|
|
"threadId": "thread_12345",
|
|
"labelIds": ["INBOX", "IMPORTANT"],
|
|
"snippet": "This is a test email about Q4 budget planning...",
|
|
"internalDate": "1605451800000",
|
|
"payload": {
|
|
"mimeType": "text/plain",
|
|
"headers": [
|
|
{"name": "From", "value": "john@company.com"},
|
|
{"name": "To", "value": "user@company.com"},
|
|
{"name": "Subject", "value": "Q4 Budget Discussion"},
|
|
{"name": "Date", "value": "Sun, 15 Nov 2020 14:30:00 +0000"},
|
|
],
|
|
"body": {
|
|
"data": base64.urlsafe_b64encode(
|
|
b"Hi team,\n\nI wanted to discuss our Q4 budget allocation.\n\nBest,\nJohn"
|
|
).decode()
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def test_processor():
|
|
"""Quick test function to verify processor works"""
|
|
|
|
processor = GmailProcessor()
|
|
sample_data = create_sample_gmail_response()
|
|
|
|
try:
|
|
result = processor.parse_email(sample_data, "test_user_123")
|
|
|
|
print("✅ GmailProcessor Test Results:")
|
|
print(f" Email ID: {result['email_id']}")
|
|
print(f" Subject: {result['metadata']['subject']}")
|
|
print(f" From: {result['metadata']['from']}")
|
|
print(f" Date: {result['metadata']['date']}")
|
|
print(f" Body length: {result['metadata']['body_length']} chars")
|
|
print(f" Has attachments: {result['metadata']['has_attachments']}")
|
|
print(f"\n Document text preview:")
|
|
print(f" {result['document_text'][:200]}...")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"❌ Test failed: {e}")
|
|
return False
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Run test when executed directly
|
|
test_processor()
|