Files
open-webui/backend/open_webui/utils/gmail_attachment_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

208 lines
7.6 KiB
Python

"""
Gmail Attachment Processor
Processes email attachments using unstructured.io for content extraction.
Integrates with Gmail sync to make attachments searchable.
"""
import logging
import tempfile
import os
import asyncio
import concurrent.futures
from typing import Optional, List, Dict
from pathlib import Path
from open_webui.utils.temp_cleanup import ensure_cache_space
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
# Thread pool for CPU-bound attachment processing (prevents blocking event loop)
_ATTACHMENT_EXECUTOR = concurrent.futures.ThreadPoolExecutor(max_workers=2, thread_name_prefix="gmail_att")
class GmailAttachmentProcessor:
"""
Process email attachments using unstructured.io.
Supports:
- PDFs (with OCR)
- Office documents (Word, Excel, PowerPoint)
- Text files, CSV, Markdown
- Images (with OCR)
- And more via unstructured.io
"""
def __init__(
self,
max_size_mb: int = 10,
allowed_extensions: str = ".pdf,.docx,.doc,.xlsx,.xls,.pptx,.ppt,.txt,.csv,.md,.html,.eml",
):
"""
Initialize attachment processor.
Args:
max_size_mb: Maximum attachment size to process (in MB)
allowed_extensions: Comma-separated list of allowed file extensions
"""
self.max_size_bytes = max_size_mb * 1024 * 1024
self.allowed_extensions = [ext.strip().lower() for ext in allowed_extensions.split(",")]
logger.info(f"GmailAttachmentProcessor initialized: max_size={max_size_mb}MB")
logger.info(f"Allowed types: {', '.join(self.allowed_extensions)}")
def is_processable(self, filename: str, size: int) -> bool:
"""
Check if attachment should be processed.
Args:
filename: Attachment filename
size: Attachment size in bytes
Returns:
True if attachment meets criteria for processing
"""
# Check size
if size > self.max_size_bytes:
logger.debug(f"Skipping '{filename}': too large ({size} bytes > {self.max_size_bytes})")
return False
# Check extension
file_ext = Path(filename).suffix.lower()
if file_ext not in self.allowed_extensions:
logger.debug(f"Skipping '{filename}': extension '{file_ext}' not in allowed list")
return False
return True
async def process_attachment(
self, attachment_data: bytes, filename: str, email_id: str
) -> Optional[Dict[str, any]]:
"""
Extract text content from attachment using unstructured.io.
Args:
attachment_data: Raw attachment bytes
filename: Attachment filename
email_id: Email ID (for logging/tracking)
Returns:
Dict with extracted text and metadata, or None if processing failed
"""
try:
# Import unstructured loader
from open_webui.retrieval.loaders.unstructured_loader import UnstructuredUnifiedLoader
# Ensure cache space before creating temp files (prevents /tmp overflow on Render)
# Estimate needed space: attachment size + some processing overhead
needed_mb = max(10, len(attachment_data) // (1024 * 1024) + 5)
ensure_cache_space(required_mb=needed_mb)
# Create temporary file for processing
with tempfile.NamedTemporaryFile(
delete=False, suffix=Path(filename).suffix, prefix=f"gmail_att_{email_id[:8]}_"
) as temp_file:
temp_file.write(attachment_data)
temp_path = temp_file.name
try:
# Process with unstructured.io
logger.info(f"Processing attachment '{filename}' ({len(attachment_data)} bytes) with unstructured.io")
# Run synchronous document loading in thread executor to prevent blocking event loop
# This is critical for keeping the server responsive during large attachment processing
def _load_document_sync():
loader = UnstructuredUnifiedLoader(
file_path=temp_path,
# Use fast strategy for attachments (performance over quality)
strategy="fast",
# Use basic chunking for attachments
chunking_strategy="basic",
max_characters=1000, # Smaller chunks for attachments
chunk_overlap=100,
cleaning_level="standard", # Standard cleaning
)
return loader.load()
# Extract documents in thread pool (non-blocking)
loop = asyncio.get_running_loop()
documents = await loop.run_in_executor(_ATTACHMENT_EXECUTOR, _load_document_sync)
# Yield to event loop after heavy processing
await asyncio.sleep(0)
if not documents:
logger.warning(f"No content extracted from '{filename}'")
return None
# Combine all chunks into single text
extracted_text = "\n\n".join([doc.page_content for doc in documents])
# Extract metadata from first document
metadata = documents[0].metadata if documents else {}
logger.info(f"Extracted {len(extracted_text)} chars from '{filename}'")
return {
"filename": filename,
"text": extracted_text,
"char_count": len(extracted_text),
"chunk_count": len(documents),
"file_type": Path(filename).suffix.lower(),
"metadata": metadata,
}
finally:
# Clean up temporary file
try:
os.unlink(temp_path)
except Exception as e:
logger.warning(f"Failed to delete temp file {temp_path}: {e}")
except Exception as e:
logger.error(f"Error processing attachment '{filename}': {e}")
return None
async def process_attachments_batch(self, attachments_info: List[Dict], fetcher, email_id: str) -> List[Dict]:
"""
Process multiple attachments in batch.
Args:
attachments_info: List of attachment metadata from gmail_processor
fetcher: GmailFetcher instance for downloading
email_id: Email ID
Returns:
List of processed attachment results
"""
processed = []
for att_info in attachments_info:
filename = att_info.get("filename", "")
size = att_info.get("size", 0)
attachment_id = att_info.get("attachmentId")
# Check if should process
if not self.is_processable(filename, size):
continue
# Download attachment
logger.info(f"Downloading attachment '{filename}' from email {email_id[:8]}...")
attachment_data = await fetcher.fetch_attachment(email_id, attachment_id, filename)
if not attachment_data:
logger.warning(f"Failed to download '{filename}'")
continue
# Process attachment
result = await self.process_attachment(attachment_data, filename, email_id)
if result:
processed.append(result)
else:
logger.warning(f"Failed to process '{filename}'")
logger.info(f"Processed {len(processed)}/{len(attachments_info)} attachments for email {email_id[:8]}")
return processed