Files
open-webui/backend/open_webui/utils/gmail_processor.py
T
PVBLIC Foundation 972b0d8f34 Merge upstream v0.9.5 into main
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
2026-05-11 07:44:29 -07:00

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()