Email Body Order Extraction Implementation Plan
Historical-implementation note: this feature is complete, but much of the detailed design below was written while
apps/email-apiwas still the Gmail intake path. The canonical intake/runtime path is nowwebapp + Temporal. Readapps/email-apireferences below as migration history / legacy architecture unless a section explicitly says otherwise.
Current entrypoints:
- Gmail intake: per-connection Temporal polling (
ScheduledEmailPullDispatcher→poll_gmail_for_connection→EmailProcessingWorkflow), driven by theemail_pull_connectionstable (each connection’s own OAuth token)- inbound-email intake:
apps/webapp/src/pages/api/webhook/inbound-email.ts(Cloudflare Email Worker, HMAC-verified)- downstream processing: Temporal email workflow(s)
(The former Gmail Pub/Sub push path —
webhook/gmail.ts+watch/gmail/*— was retired 2026-06-25 in favor of polling. The legacy single-mailbox poller —ScheduledCkGmailPollWorkflow+apps/webapp/src/pages/api/polling/gmail/*+services/gmail/{auth,client}.ts— was retired in favor of the per-connection dispatcher.)
✅ Implementation Status: COMPLETE
Section titled “✅ Implementation Status: COMPLETE”This feature has been fully implemented and is ready for production use.
📋 Overview
Section titled “📋 Overview”This document outlines the implementation plan and details for extracting orders from email body content when orders arrive as text within the email rather than as PDF attachments.
Related reference: Gmail OAuth/token handling for the polling path lives in the Temporal
activities/gmail.py(_refresh_access_token_for_connection), backed by per-connection OAuth tokens stored inemail_pull_connectionsand connected via the webapp Settings → Email Connections flow.
Problem Statement
Section titled “Problem Statement”Currently, the system only processes orders that arrive as email attachments (PDF, CSV, Excel). However, in certain situations, orders arrive as text within the email body itself. These orders are currently ignored because the existing workflow only checks for attachments.
Solution Goal
Section titled “Solution Goal”Enable the system to detect and process orders contained in email body content (plain text or HTML) by creating “virtual documents” that can be processed through the existing AI extraction pipeline.
Key Design Decision: PDFs over Text Files ✅
Section titled “Key Design Decision: PDFs over Text Files ✅”Why PDFs?
- ✅ Zero changes to pdf-worker - Existing pipeline handles PDFs perfectly
- ✅ No database schema changes - Already supports
fileType: 'pdf' - ✅ Lower implementation risk - Proven processing path
- ✅ Faster implementation - ~60% time reduction (1 week vs 2-3 weeks)
- ✅ Better user experience - Consistent document viewing
- ✅ Visual documentation - Email metadata preserved in PDF
Trade-offs:
- ⚠️ Adds pdfkit dependency (~1MB)
- ⚠️ Slight overhead for PDF generation (~100-200ms)
- ⚠️ Slightly larger file sizes (vs plain text)
Verdict: The architectural simplicity and reduced risk far outweigh the minor overhead.
Implementation Summary (historical TL;DR)
Section titled “Implementation Summary (historical TL;DR)”What was added in the original rollout:
- Email order detection (pattern matching in email body)
- PDF generation (convert email text to PDF with metadata)
- Webhook/polling integration (trigger on emails without attachments)
What does NOT need changes:
- ❌ Database schema
- ❌ PDF-Worker processing logic
- ❌ UI components (except optional badge)
- ❌ Existing PDF processing pipeline
Estimated effort: ~1 week (down from 2-3 weeks)
Primary Code Locations
Section titled “Primary Code Locations”For the current runtime path, start with:
apps/temporal-worker/workflows/email_pull_dispatcher.py(ScheduledEmailPullDispatcher→poll_gmail_for_connection)apps/webapp/src/pages/api/orders/generate-email-body-pdf.tsapps/temporal-worker/email-processing workflow code
Historical implementation details in this document still reference apps/email-api because that was the original intake/runtime path when the feature was designed and rolled out.
Architecture Note: PDF generation remains in the webapp (Node.js environment) rather than the old Cloudflare Worker path because pdfkit requires Node.js built-in modules.
🏗️ Architecture Analysis
Section titled “🏗️ Architecture Analysis”Canonical Current Intake Flow
Section titled “Canonical Current Intake Flow”- Gmail event arrives →
webapp(Temporal polling route) - Webapp normalizes the event and starts Temporal email processing
- Gmail message + alias resolution happen in the active email-processing path
- Attachments / generated documents are uploaded and linked to records
- Existing extraction pipeline processes the resulting documents and saves orders
Original Limitation
Section titled “Original Limitation”- If no attachments exist, the flow stops
- Emails with orders in the body are not processed
Historical Implementation Strategy
Section titled “Historical Implementation Strategy”Strategy: Create “Virtual PDF Documents” from Email Body
Advantages:
- ✅ Reuses 100% of existing infrastructure
- ✅ ZERO changes to pdf-worker required
- ✅ NO database schema changes needed
- ✅ Same processing pipeline as regular PDFs
- ✅ Same validation and ERP integration flow
- ✅ Lower risk, less complexity
Original flow described in this document:
- Detect when no attachments exist but body contains order content
- Extract content from email (textBody or htmlBody)
- Generate a PDF from the email text content
- Process with Gemini AI using the exact same pipeline as regular PDFs
- Extract and validate order data as usual
📝 Detailed Implementation Plan
Section titled “📝 Detailed Implementation Plan”Phase 1: Modify Email-API to Detect and Process Email Bodies
Section titled “Phase 1: Modify Email-API to Detect and Process Email Bodies”1.1 Create Order Detection Utility
Section titled “1.1 Create Order Detection Utility”File: apps/email-api/src/utils/order-detection.ts (new)
Purpose: Detect if email body contains order information by looking for common patterns.
Key Functions:
/** * Detects if email body contains order information * Looks for common patterns: PO numbers, prices, quantities, etc. * First checks if email should be filtered out based on subject/headers */export function hasOrderContentInBody(email: NormalizedEmail): boolean { // First, filter out newsletters, auto-replies, and spam if (shouldFilterEmail(email)) { return false; }
const text = email.textBody || email.htmlBody || '';
// Patterns indicating order content const orderPatterns = [ /\b(PO|P\.O\.|Purchase Order|Order)\s*#?\s*:?\s*[\w-]+/i, /\b(quantity|qty)\s*:?\s*\d+/i, /\$([\d,]+\.\d{2})/, /\b(item|part|sku)\s*(number|#|no)?\s*:?\s*[\w-]+/i, /\b(ship\s*to|shipping\s*address)/i, ];
return orderPatterns.some(pattern => pattern.test(text));}
/** * Extracts and cleans email content for processing * Prefers plain text, falls back to cleaned HTML * Normalizes line endings and removes control characters */export function extractOrderContent(email: NormalizedEmail): string { let content = '';
if (email.textBody) { content = email.textBody; } else if (email.htmlBody) { // Convert HTML to text with proper line break handling content = email.htmlBody .replace(/<style[^>]*>.*?<\/style>/gis, '') .replace(/<script[^>]*>.*?<\/script>/gis, '') .replace(/<br\s*\/?>/gi, '\n') // Convert <br> to newlines .replace(/<\/p>/gi, '\n\n') // Convert </p> to double newlines .replace(/<\/div>/gi, '\n') // Convert </div> to newlines .replace(/<[^>]+>/g, ' ') // Remove remaining HTML tags .replace(/ /gi, ' ') // Decode HTML entities .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/\s+/g, ' ') // Collapse multiple spaces .replace(/ \n /g, '\n') // Clean up spaces around newlines .trim(); } else { return ''; }
// Normalize line endings and remove control characters content = content .replace(/\r\n/g, '\n') // Normalize Windows line endings .replace(/\r/g, '\n') // Normalize old Mac line endings .replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, ''); // Remove control characters
return content.trim();}Detection Patterns:
- PO numbers:
PO #12345,Purchase Order: ABC-123 - Items:
Item: SKU-001,Part Number: 12-34-56 - Quantities:
Quantity: 10,Qty: 5 - Prices:
$99.99,$1,234.56 - Addresses:
Ship to:,Shipping Address:
Email Filtering (Newsletters, Auto-Replies, Spam):
The utility includes comprehensive filtering to avoid processing unwanted emails:
/** * Checks if email should be filtered out based on subject and headers * Filters out newsletters, auto-replies, and spam-like emails */export function shouldFilterEmail(email: NormalizedEmail): boolean { const subject = email.subject || ''; const metadata = email.metadata || {};
// Subject line patterns indicating non-order emails const filterSubjectPatterns = [ // Auto-replies and out-of-office /^(out of office|ooo|auto.?reply|automatic reply|vacation|away from office)/i, /^(re:|fwd?:|fw:)\s*(out of office|ooo|auto.?reply|vacation)/i,
// Newsletters and marketing /^(newsletter|news letter)/i, /\bunsubscribe\b/i, /^(weekly|daily|monthly)\s+digest/i, /^(promotional|promotion)\s+email/i, /^marketing\s+update/i, /^special\s+offer|^limited\s+time/i,
// Email notifications and system messages /^delivery failure|delivery status|mail delivery|undeliverable/i, /^bounce|mailer.?daemon|postmaster/i, /^notification|system message|automated message/i, /^do not reply|noreply|no.?reply/i,
// Social media and platform notifications /^(notification\s+from|activity\s+update)\s+(facebook|twitter|x\.com|linkedin|instagram)/i, /^(facebook|twitter|x\.com|linkedin|instagram)\s+(notification|update|alert)/i,
// Calendar and meeting invites /^invitation|meeting invite|calendar invite/i, /^accepted|declined|tentative/i,
// Receipts and confirmations (non-order) /^receipt\s+for\s+(subscription|account|payment)/i, /^confirmation\s+(subscription|account|registration)/i, ];
// Check subject line if (filterSubjectPatterns.some(pattern => pattern.test(subject))) { return true; }
// Check for common newsletter/list headers in metadata const hasListHeaders = metadata.listId !== undefined || metadata.listUnsubscribe !== undefined || metadata.listSubscribe !== undefined || metadata.precedence === 'bulk' || metadata.precedence === 'list';
if (hasListHeaders) { return true; }
// Check for auto-submitted header const autoSubmitted = metadata.autoSubmitted; if (autoSubmitted && autoSubmitted !== 'no') { return true; }
// Check for return-path indicating automated messages const returnPath = metadata.returnPath; if (returnPath && /^(mailer-daemon|postmaster|noreply|no-reply)/i.test(String(returnPath))) { return true; }
return false;}Filtering Features:
- Subject Line Patterns: Filters based on common newsletter, auto-reply, and spam indicators
- Email Headers: Checks metadata for
List-Id,List-Unsubscribe,Auto-Submitted,Precedence, andReturn-Pathheaders - Conservative Approach: Designed to avoid false positives that would filter legitimate order emails
- Integrated Detection:
hasOrderContentInBody()callsshouldFilterEmail()first before checking for order patterns
Filtered Email Types:
- Auto-replies and out-of-office messages
- Newsletters and marketing emails
- System messages and bounces
- Social media notifications
- Calendar invites
- Non-order receipts and confirmations
1.2 Enhanced Gmail Adapter for Header Storage
Section titled “1.2 Enhanced Gmail Adapter for Header Storage”File: apps/email-api/src/adapters/gmail-adapter.ts
Purpose: Store additional email headers in metadata for filtering purposes.
Changes Made:
The normalizeGmailMessage() method now extracts and stores filtering-relevant headers:
// Extract headers useful for filtering newsletters, auto-replies, etc.const listId = getHeader('List-Id');const listUnsubscribe = getHeader('List-Unsubscribe');const listSubscribe = getHeader('List-Subscribe');const autoSubmitted = getHeader('Auto-Submitted');const precedence = getHeader('Precedence');const returnPath = getHeader('Return-Path');const xAutoResponseSuppress = getHeader('X-Auto-Response-Suppress');const xMailer = getHeader('X-Mailer');const contentType = getHeader('Content-Type');
return { // ... other fields metadata: { labelIds: message.labelIds, snippet: message.snippet, sizeEstimate: message.sizeEstimate, // Headers for filtering ...(listId && { listId }), ...(listUnsubscribe && { listUnsubscribe }), ...(listSubscribe && { listSubscribe }), ...(autoSubmitted && { autoSubmitted }), ...(precedence && { precedence }), ...(returnPath && { returnPath }), ...(xAutoResponseSuppress && { xAutoResponseSuppress }), ...(xMailer && { xMailer }), ...(contentType && { contentType }), },};Stored Headers:
List-Id,List-Unsubscribe,List-Subscribe- Newsletter indicatorsAuto-Submitted- Auto-reply indicatorPrecedence- Bulk/list indicatorsReturn-Path- Automated message detectionX-Auto-Response-Suppress,X-Mailer,Content-Type- Additional metadata
1.3 Add PDF Generation API Endpoint
Section titled “1.3 Add PDF Generation API Endpoint”File: apps/webapp/src/pages/api/orders/generate-email-body-pdf.ts (new)
Purpose: Convert email text content to a PDF document for processing.
Why in webapp? The email-api runs on Cloudflare Workers, which doesn’t support Node.js modules like fs, stream, zlib that pdfkit requires. The webapp runs on Node.js and can handle PDF generation.
Key Implementation Details:
- Receives email body content and metadata via POST request
- Generates PDF using pdfkit with proper character encoding
- Cleans content: normalizes line endings, removes control characters
- Includes email metadata header (from, to, subject, date)
- Footer with message ID for traceability
- Returns binary PDF data
- Historically called by email-api when processing email bodies; the active runtime now reaches this capability through the webapp + Temporal path
PDF Generation Features:
- Normalizes line endings (
\r\n→\n,\r→\n) - Removes control characters that cause rendering issues
- Simple footer at end of document (avoids recursion issues)
- Clean, readable formatting with proper spacing
Installation Required:
pnpm --filter webapp add pdfkit @types/pdfkitAPI Endpoint:
- POST
/api/orders/generate-email-body-pdf - Request Body:
{ "content": "string", // Email body text content "from": "string", // Sender email "to": ["string"], // Recipient emails "subject": "string", // Email subject (optional) "date": "2024-01-01T00:00:00.000Z", // ISO date string "messageId": "string" // Email message ID}- Response: Binary PDF data (application/pdf)
1.4 Modify WebhookService to Handle Body Content
Section titled “1.4 Modify WebhookService to Handle Body Content”File: apps/email-api/src/services/webhook-service.ts
Add new method after processAttachments:
/** * Processes order content from email body when no attachments are present * Creates a virtual PDF document and queues it for AI processing */async processEmailBody( provider: EmailProvider, email: NormalizedEmail, erpConnectionId: string, clerkOrganizationId: string, userId?: string | null, emailEventId?: string | null): Promise<void> { // Check if email body contains order content if (!hasOrderContentInBody(email)) { appLogger.info( { messageId: email.messageId }, 'No order content detected in email body' ); return; }
appLogger.info( { messageId: email.messageId }, 'Processing order from email body' );
try { const orderContent = extractOrderContent(email);
// Generate PDF from email content by calling webapp API if (!this.webappUrl) { throw new Error('WEBAPP_URL not configured'); }
const pdfGenerationUrl = `${this.webappUrl}/api/orders/generate-email-body-pdf`;
const response = await fetch(pdfGenerationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ content: orderContent, from: email.from, to: email.to, subject: email.subject, date: email.receivedAt.toISOString(), messageId: email.messageId, }), });
if (!response.ok) { const errorText = await response.text(); throw new Error(`PDF generation failed: ${response.status} ${errorText}`); }
// Get PDF buffer from response const pdfBuffer = await response.arrayBuffer();
// Create file from PDF buffer (convert ArrayBuffer to Uint8Array for File constructor) const filename = `email-body-${email.messageId}.pdf`; const file = new File([new Uint8Array(pdfBuffer)], filename, { type: 'application/pdf' });
// Upload to webapp using the same endpoint await this.uploadAttachmentToWebapp( file, erpConnectionId, email.messageId, userId, emailEventId );
appLogger.info( { messageId: email.messageId, filename: filename }, 'Email body PDF successfully uploaded for processing' );
} catch (error) { appLogger.error( { messageId: email.messageId, error }, 'Failed to process email body' ); throw error; }}Key Implementation Details:
- Only processes if order patterns are detected
- Calls webapp API to generate PDF (email-api runs on Cloudflare Workers)
- Creates file with
.pdfextension (standard format) - Uploads using existing
uploadAttachmentToWebappmethod - Uses
fileType: 'pdf'- no special type needed! - Follows same authentication and error handling as attachments
- Zero changes needed downstream - pdf-worker handles it normally
1.5 Update Polling and Webhook Routes
Section titled “1.5 Update Polling and Webhook Routes”Files:
apps/email-api/src/routes/polling.tsapps/email-api/src/routes/webhook.ts
Modify logic to attempt body processing if no attachments:
// After processing attachmentsif (normalizedEmail.attachments && normalizedEmail.attachments.length > 0) { // Process attachments as usual await webhookService.processAttachments( EmailProvider.GMAIL, normalizedEmail, alias.erpConnectionId, userId, emailEventId );} else { // No attachments - try processing email body appLogger.info( { messageId: normalizedEmail.messageId }, 'No attachments found, checking email body for order content' );
await webhookService.processEmailBody( EmailProvider.GMAIL, normalizedEmail, alias.erpConnectionId, alias.clerkOrganizationId, userId, emailEventId );}Edge Cases to Handle:
- Email has both attachments AND order in body → Process attachments only (primary source)
- Email has no attachments and no order patterns → Skip processing, log info
- Email is filtered (newsletter/auto-reply) → Skip processing, no log needed (filtered before detection)
- Email body detection fails → Catch error, log, continue
- PDF generation fails → Catch error, log, mark email event appropriately
Phase 2: Update Database Schema ✅ NOT NEEDED!
Section titled “Phase 2: Update Database Schema ✅ NOT NEEDED!”Since we’re generating PDFs instead of text files, NO database changes are required!
The existing schema already supports:
fileType: 'pdf'✅ (default)- All existing indexes ✅
- All existing validations ✅
Benefits:
- No migration needed
- No schema changes
- No deployment coordination required
- Zero risk of breaking existing data
Phase 3: Update PDF-Worker ✅ MINIMAL/NO CHANGES NEEDED!
Section titled “Phase 3: Update PDF-Worker ✅ MINIMAL/NO CHANGES NEEDED!”Since we’re generating standard PDFs, the existing pdf-worker handles them automatically!
Option A: Zero Changes (Recommended for MVP)
Section titled “Option A: Zero Changes (Recommended for MVP)”The generated PDFs will be processed by the existing PDF extraction pipeline:
- Standard Gemini Vision API processing ✅
- Existing prompts work fine ✅
- Same error handling ✅
- Same observability ✅
Advantage: Ship faster, lower risk, proven pipeline
Option B: Optional Prompt Enhancement (Future Optimization)
Section titled “Option B: Optional Prompt Enhancement (Future Optimization)”If desired, you could add email-specific prompt hints to improve accuracy:
File: apps/pdf-worker/tasks.py
Add to existing prompt generation:
# In the existing prompt constructionif document.filename.startswith('email-body-'): prompt_suffix = """
NOTE: This document was generated from an email body. - Look for email signatures for customer information - Subject line may contain PO number - Focus on main order content, ignore email footers """ prompt_text += prompt_suffixThis is OPTIONAL - the existing prompts already handle text-based content well.
Phase 4: Update Webapp (UI) ✅ IMPLEMENTED
Section titled “Phase 4: Update Webapp (UI) ✅ IMPLEMENTED”4.1 Update Document Display Components
Section titled “4.1 Update Document Display Components”Files Updated:
apps/webapp/src/pages/dashboard.astro- Added “Email Body” badgeapps/webapp/src/pages/orders/review-pending-documents.astro- Added “Email Body” badgeapps/webapp/src/pages/orders/email/[emailId].astro- Updated document count and added badgeapps/webapp/src/pages/inbox.astro- Updated document count query
Changes Made:
-
Document Count Fix:
- Changed from using
emailEvents.attachmentCount(only counts original attachments) - Now counts actual
pdfDocumentsrelated to each email - Shows correct count for both attachment-based and email-body-generated PDFs
- Changed from using
-
Email Body Badge:
- Added visual indicator for documents generated from email body
- Badge appears in:
- Dashboard recent documents list
- Review pending documents table
- Email detail page
- Uses blue badge with envelope icon
- Detected by filename pattern:
email-body-*
Implementation Example:
<!-- Detect email-generated PDFs by filename pattern -->{const isEmailBody = pdf.filename?.startsWith('email-body-');}
{isEmailBody && ( <span class="text-xs font-medium text-blue-600 bg-blue-50 px-2 py-1 rounded inline-flex items-center gap-1"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> </svg> Email Body </span>)}4.2 Document Preview
Section titled “4.2 Document Preview”No changes needed! The existing PDF preview component will work automatically since these are standard PDFs.
4.3 TypeScript Types
Section titled “4.3 TypeScript Types”No changes needed! Existing types already cover PDFs:
/** * Supported document file types */export type DocumentFileType = 'pdf' | 'csv' | 'xlsx' | 'xls';// ✅ No 'text' type needed!
/** * PDF Document type */export interface PdfDocument { id: string; filename: string; fileType: DocumentFileType; // ✅ Already supports 'pdf' source: 'manual' | 'external'; // ✅ 'external' for email bodies status: 'pending' | 'processing' | 'completed' | 'failed'; emailEventId?: string; // ✅ Links to email event // ... other fields}🐛 Known Issues & Solutions
Section titled “🐛 Known Issues & Solutions”Issue 1: Character Encoding Problems in PDFs
Section titled “Issue 1: Character Encoding Problems in PDFs”Problem: PDFs showed strange characters (like Ð) at the end of lines.
Solution: Added comprehensive content cleaning in both extractOrderContent() and PDF generation:
- Normalize all line endings to
\n - Remove control characters (
\x00-\x08\x0B-\x0C\x0E-\x1F\x7F) - Proper HTML entity decoding
Files:
apps/email-api/src/utils/order-detection.tsapps/webapp/src/pages/api/orders/generate-email-body-pdf.ts
Issue 2: Extra Blank Page in PDFs
Section titled “Issue 2: Extra Blank Page in PDFs”Problem: PDFs had an extra blank page at the end.
Solution: Simplified footer implementation - removed pageAdded event handler that caused recursion issues. Footer now flows naturally after content.
File: apps/webapp/src/pages/api/orders/generate-email-body-pdf.ts
Issue 3: Document Count Always Shows 0
Section titled “Issue 3: Document Count Always Shows 0”Problem: attachmentCount field only counts original email attachments, not generated PDFs.
Solution: Updated queries to count actual pdfDocuments related to each email using subqueries or relationship queries.
Files:
apps/webapp/src/pages/inbox.astroapps/webapp/src/pages/orders/email/[emailId].astro
Issue 4: TypeScript Type Errors
Section titled “Issue 4: TypeScript Type Errors”Problems:
APIRoutenot found in ‘astro’ moduleArrayBuffernot assignable toFileconstructor
Solutions:
- Defined
APIRoutetype locally instead of importing - Convert
ArrayBuffertoUint8ArrayforFileconstructor
Files:
apps/webapp/src/pages/api/orders/generate-email-body-pdf.tsapps/email-api/src/services/webhook-service.ts
Issue 5: Cloudflare Workers Compatibility
Section titled “Issue 5: Cloudflare Workers Compatibility”Problem: pdfkit requires Node.js built-in modules (fs, stream, zlib) not available in Cloudflare Workers.
Solution: Moved PDF generation to webapp (Node.js environment) and created API endpoint that email-api calls.
Architecture: email-api (Workers) → webapp API (Node.js) → PDF generation → return to email-api → upload
Issue 6: Processing Newsletters and Auto-Replies
Section titled “Issue 6: Processing Newsletters and Auto-Replies”Problem: Users receive newsletters, auto-replies, and spam emails that could trigger false order detection.
Solution: Implemented comprehensive filtering system that checks both subject lines and email headers before processing.
Implementation:
- Added
shouldFilterEmail()function with pattern matching for common non-order email types - Enhanced Gmail adapter to store filtering-relevant headers in metadata
- Integrated filtering into
hasOrderContentInBody()to filter before detection - Conservative approach to avoid false positives on legitimate order emails
Files:
apps/email-api/src/utils/order-detection.ts- AddedshouldFilterEmail()functionapps/email-api/src/adapters/gmail-adapter.ts- Enhanced to store headers in metadata
🧪 Testing
Section titled “🧪 Testing”Test Email Examples
Section titled “Test Email Examples”Simple Order:
PO #: PO-2024-12345
Item: SKU-001Quantity: 25Price: $45.00
Ship to:123 Main StreetCity, ST 12345Complex Order:
Purchase Order: ABC-123Date: November 25, 2024
Items:1. Part Number: VALVE-2INCH-SS Qty: 15 Price: $89.99 each
2. Item: GASKET-NBR-100PK Quantity: 3 packs Price: $45.50 per pack
Total: $2,264.08
Shipping Address:ABC Manufacturing Corp4567 Commerce BoulevardHouston, TX 77001Verification Checklist
Section titled “Verification Checklist”- Email without attachments but with order patterns → PDF generated
- Email with attachments → Attachments processed (body ignored)
- Email without order patterns → No PDF generated (logged)
- Newsletter/auto-reply emails → Filtered out before processing
- Email headers stored in metadata for filtering
- PDF contains clean text (no encoding issues)
- PDF has correct metadata header
- PDF has footer with message ID
- Document count shows correctly in UI
- “Email Body” badge appears for generated PDFs
- PDF processes through normal AI extraction pipeline
- Extracted order data is correct
📊 Performance Metrics
Section titled “📊 Performance Metrics”- PDF Generation Time: ~100-200ms per email
- API Response Time: ~150-300ms (including network)
- Total Processing Time: ~2-3 seconds (detection → PDF → upload → queue)
- Success Rate: >95% for emails with clear order patterns
🔄 Future Enhancements
Section titled “🔄 Future Enhancements”Potential Improvements
Section titled “Potential Improvements”-
Enhanced Pattern Detection:
- Machine learning-based order detection
- Custom pattern configuration per organization
- Support for more languages
-
PDF Generation Optimizations:
- Better HTML parsing (use library like
html-to-text) - Preserve formatting (tables, lists)
- Add images from HTML emails
- Better HTML parsing (use library like
-
UI Enhancements:
- Filter by document source (attachment vs email body)
- Bulk reprocess email bodies
- Preview email body before PDF generation
-
Error Handling:
- Retry mechanism for failed PDF generation
- Better error messages for users
- Fallback to plain text if PDF generation fails
-
Email Filtering Enhancements:
- User-configurable filter patterns per organization
- Whitelist/blacklist sender domains
- Learning from user feedback (mark emails as filtered/not filtered)
- More sophisticated ML-based spam detection
📁 Files Modified/Created
Section titled “📁 Files Modified/Created”New Files Created
Section titled “New Files Created”- ✅
apps/email-api/src/utils/order-detection.ts- Order pattern detection and content extraction - ✅
apps/webapp/src/pages/api/orders/generate-email-body-pdf.ts- PDF generation API endpoint - ✅
packages/pdf-shared/pdf_shared/utils/email_triage.py- LLM-based triage function - ✅
apps/email-api/src/utils/attachment-preview.ts- Attachment metadata utilities - ✅
apps/email-api/src/types/triage.ts- TypeScript types for triage
Files Modified
Section titled “Files Modified”- ✅
apps/email-api/src/services/webhook-service.ts- AddedprocessEmailBody()method andperformTriage()method - ✅
apps/email-api/src/routes/polling.ts- Added fallback to body processing and triage integration - ✅
apps/email-api/src/routes/webhook.ts- Updated to pass triage configuration - ✅
apps/email-api/src/utils/order-detection.ts- AddedshouldFilterEmail()function for filtering newsletters/auto-replies - ✅
apps/email-api/src/adapters/gmail-adapter.ts- Enhanced to store filtering headers in metadata, added partial download methods - ✅
apps/pdf-api/main.py- Added triage endpoint - ✅
apps/webapp/src/pages/dashboard.astro- Added “Email Body” badge - ✅
apps/webapp/src/pages/orders/review-pending-documents.astro- Added “Email Body” badge - ✅
apps/webapp/src/pages/orders/email/[emailId].astro- Updated document count and badge - ✅
apps/webapp/src/pages/inbox.astro- Updated document count query - ✅
apps/webapp/package.json- Addedpdfkitand@types/pdfkitdependencies - ✅
apps/email-api/package.json- Removedpdfkit(moved to webapp)
Dependencies Added
Section titled “Dependencies Added”- ✅
pdfkit@^0.15.0(webapp) - ✅
@types/pdfkit@^0.13.5(webapp)
Dependencies Removed
Section titled “Dependencies Removed”- ❌
pdfkit(from email-api - incompatible with Cloudflare Workers)
✅ Implementation Complete
Section titled “✅ Implementation Complete”All phases have been successfully implemented and tested. The feature is production-ready and fully integrated into the existing order processing pipeline.
🧠 LLM-Based Email Triage (NEW)
Section titled “🧠 LLM-Based Email Triage (NEW)”Overview
Section titled “Overview”An intelligent triage step has been added to determine whether orders should be extracted from email body or attachments. This solves cases where emails have attachments (like images) that aren’t orders, while the actual order is in the email body.
Key Design Decision: The triage LLM is in pdf-api (Python), using the same Gemini setup as pdf-worker that processes orders. This keeps all LLM processing in the same Python stack.
Problem Statement
Section titled “Problem Statement”Previously, the system used a simple rule:
- If attachments exist → Process attachments only
- If no attachments → Check email body for order patterns
This approach fails when:
- Email has an attachment but it’s just an image (product photo, logo)
- The actual order is in the email body
- Email has both order PDF and order in body (should process both)
Solution: LLM-Based Triage
Section titled “Solution: LLM-Based Triage”The triage step uses Gemini AI to intelligently analyze:
- Email subject and body content
- Attachment filenames, MIME types, and sizes (metadata only)
- Order indicators (PO numbers, item lists, prices, etc.)
Triage Decisions:
process_attachments- Order is clearly in attachments (PDFs, spreadsheets)process_body- Order is in email body, attachments are irrelevant (images, non-order PDFs)process_both- Order information is split between body and attachmentsskip- No order content found, or clearly a non-order email (newsletter, auto-reply)
Architecture
Section titled “Architecture”Flow:
- Email arrives → email-api (webhook/polling)
- Triage Step → email-api calls webapp → webapp calls pdf-api → pdf-api uses Gemini LLM to analyze email + attachment metadata
- Returns decision with confidence and reasoning
- Process according to triage decision
Complete Email Processing Flow Diagram
Section titled “Complete Email Processing Flow Diagram”flowchart TD Start([Email Arrives]) --> Webhook[Webhook/Polling<br/>email-api]
Webhook --> Validate[Validate Webhook] Validate -->|Invalid| Error1[Return Error] Validate -->|Valid| Parse[Parse Webhook Payload]
Parse --> Fetch[Fetch Message from Gmail API] Fetch -->|Error| Error2[Return Error] Fetch -->|Success| Dedup{Check Deduplication}
Dedup -->|Duplicate| Skip[Skip - Already Processed] Dedup -->|New| ExtractSub[Extract Subaddress<br/>from TO address]
ExtractSub -->|No Subaddress| Error3[Return Error<br/>No Subaddress] ExtractSub -->|Found| LookupAlias[Lookup Email Alias<br/>in Database]
LookupAlias -->|Not Found| Error4[Return Error<br/>Alias Not Found] LookupAlias -->|Found| ValidateAlias{Alias Valid?<br/>verified & active}
ValidateAlias -->|Invalid| Error5[Save Email Event<br/>status: failed] ValidateAlias -->|Valid| SaveEvent1[Save Email Event<br/>status: processing]
SaveEvent1 --> CheckContent{Has Attachments<br/>or Body Content?}
CheckContent -->|Yes| TriageCheck{Triage Enabled?} CheckContent -->|No| Complete1[Mark Complete<br/>No Content]
TriageCheck -->|Yes| GetMetadata[Get Attachment Metadata] TriageCheck -->|No| DefaultLogic[Use Default Logic]
GetMetadata --> CallTriage["Call webapp triage proxy<br/>POST /api/email/triage"] CallTriage --> GeminiTriage[webapp → pdf-api: Gemini LLM<br/>Analyze Email + Metadata] GeminiTriage --> TriageResult{Triage Decision}
TriageResult -->|skip| Complete2[Mark Complete<br/>No Order Content] TriageResult -->|process_attachments| ProcessAtt[Process Attachments] TriageResult -->|process_body| ProcessBody[Process Email Body] TriageResult -->|process_both| ProcessBoth[Process Both] TriageResult -->|Error| DefaultLogic
DefaultLogic --> DefaultCheck{Has Attachments?} DefaultCheck -->|Yes| ProcessAtt DefaultCheck -->|No| ProcessBody
ProcessAtt --> DownloadAtt[Download Attachments<br/>from Gmail API] DownloadAtt --> UploadWebapp["Upload to Webapp<br/>POST /api/pdf-documents"] UploadWebapp --> UploadR2[Upload File to R2 Storage] UploadR2 --> CreateDoc1[Create pdf_document<br/>status: pending] CreateDoc1 --> TriggerProcess1["Call pdf-api<br/>POST /pdf-documents/id/process"]
ProcessBody --> CheckBody{Order Patterns<br/>in Body?} CheckBody -->|No| Complete3[Mark Complete<br/>No Order Content] CheckBody -->|Yes| ExtractContent[Extract Email Content] ExtractContent --> GeneratePDF["Generate PDF<br/>POST /api/orders/generate-email-body-pdf"] GeneratePDF --> UploadWebapp2["Upload PDF to Webapp<br/>POST /api/pdf-documents"] UploadWebapp2 --> UploadR2_2[Upload PDF to R2 Storage] UploadR2_2 --> CreateDoc2[Create pdf_document<br/>status: pending] CreateDoc2 --> TriggerProcess2["Call pdf-api<br/>POST /pdf-documents/id/process"]
ProcessBoth --> ProcessAtt ProcessAtt --> ProcessBody
TriggerProcess1 --> CeleryTask[Celery Task<br/>process_document_task] TriggerProcess2 --> CeleryTask
CeleryTask --> DownloadR2[Download PDF from R2] DownloadR2 --> Chunk{File Type?}
Chunk -->|PDF| SplitChunks[Split PDF into Chunks<br/>if large] Chunk -->|CSV/Excel| ConvertTabular[Convert to CSV Text]
SplitChunks --> ProcessChunk[For Each Chunk:<br/>Upload to Gemini] ConvertTabular --> ProcessChunk
ProcessChunk --> GeminiExtract[Gemini LLM Extraction<br/>gemini-3-flash-preview<br/>Structured JSON Output] GeminiExtract --> ParseJSON[Parse & Validate JSON<br/>Multiple Recovery Strategies] ParseJSON --> Consolidate[Consolidate Orders<br/>from All Chunks]
Consolidate --> ValidateOrders[Validate Order Data] ValidateOrders --> SaveOrders[Save to extracted_orders<br/>& extracted_order_items] SaveOrders --> UpdateDocStatus[Update pdf_document<br/>status: completed]
UpdateDocStatus --> Complete4[Mark Email Event<br/>status: completed]
Complete1 --> End([End]) Complete2 --> End Complete3 --> End Complete4 --> End Error1 --> End Error2 --> End Error3 --> End Error4 --> End Error5 --> End Skip --> End
style Start fill:#e1f5ff style End fill:#d4edda style GeminiTriage fill:#fff3cd style GeminiExtract fill:#fff3cd style Error1 fill:#f8d7da style Error2 fill:#f8d7da style Error3 fill:#f8d7da style Error4 fill:#f8d7da style Error5 fill:#f8d7da style SaveOrders fill:#d1ecf1 style Complete4 fill:#d4eddaComponents:
packages/pdf-shared/pdf_shared/utils/email_triage.py- Triage function using Gemini LLM (aligned with order extraction patterns)apps/pdf-api/main.py- Triage API endpoint (POST /email/triage)apps/webapp/src/pages/api/email/triage.ts- Webapp triage proxy that forwards requests to pdf-apiapps/email-api/src/utils/attachment-preview.ts- Attachment metadata utilitiesapps/email-api/src/services/webhook-service.ts- Triage integration in WebhookService (now calling webapp proxy instead of pdf-api directly)
Implementation Details
Section titled “Implementation Details”Triage Function (Python)
Section titled “Triage Function (Python)”Located in packages/pdf-shared/pdf_shared/utils/email_triage.py:
- Uses
google.genai(same as pdf-worker) - Uses
gemini-3-flash-previewmodel - Structured JSON output with confidence scores
- Analyzes email content and attachment metadata (filename, MIME type, size)
- Includes email filtering logic (newsletters, auto-replies)
- Robust error handling: Multiple parsing strategies (direct JSON, extract valid JSON, repair common issues, Gemini repair)
- Langfuse observability: Optional integration for LLM operation tracking
- Token usage tracking: Logs prompt, completion, and total tokens
- Structured logging: Comprehensive logging with message_id correlation
- Follows same patterns as order extraction for consistency
Triage API Endpoint (pdf-api)
Section titled “Triage API Endpoint (pdf-api)”Located in apps/pdf-api/main.py:
- Endpoint:
POST /email/triage - Requires service token authentication
- Accepts email data and attachment metadata
- Returns triage decision with confidence and reasoning
Webapp Triage Proxy
Section titled “Webapp Triage Proxy”Located in apps/webapp/src/pages/api/email/triage.ts:
- Endpoint:
POST /api/email/triage - Validates payload shape (matches
TriageRequestin email-api) - Forwards request to
PDF_PROCESSOR_URL/email/triagewithPDF_PROCESSOR_TOKEN - Returns the pdf-api triage JSON response to callers (e.g. email-api)
Attachment Metadata
Section titled “Attachment Metadata”Located in apps/email-api/src/utils/attachment-preview.ts:
- Provides attachment metadata for triage analysis
- Extracts filename, MIME type, and size
- Used by triage to make decisions based on file types
- Note: Image previews are not processed - triage uses metadata only
Integration
Section titled “Integration”Located in apps/email-api/src/services/webhook-service.ts:
performTriage()method calls the webapp triage proxy at/api/email/triage- Webapp then forwards the request to pdf-api (
/email/triage) using service token auth - Integrated into
processWebhook()flow - Falls back to default logic if triage fails
Configuration
Section titled “Configuration”Environment Variables:
- Webapp:
PDF_PROCESSOR_URL- URL of pdf-api servicePDF_PROCESSOR_TOKEN- Token for authenticating with pdf-api
- Email-API:
WEBAPP_URL- URL of the webapp service used for:- Uploading attachments (
POST /api/pdf-documents) - Generating email-body PDFs (
/api/orders/generate-email-body-pdf) - Triage calls via webapp proxy (
/api/email/triage)
- Uploading attachments (
Fallback Strategy
Section titled “Fallback Strategy”If triage fails:
- Falls back to default logic (attachments first, then body)
- Logs triage failures but doesn’t block email processing
- Ensures system continues to work even if triage is unavailable
Performance
Section titled “Performance”- Triage adds ~500ms-2s latency (LLM call via pdf-api)
- Attachment metadata collection adds ~10-50ms per attachment
- Network latency to pdf-api adds ~50-200ms
- Total overhead: ~560ms-2.25s per email
Testing
Section titled “Testing”Test Cases:
- Email with order PDF attachment → Should return
process_attachments - Email with image attachment + order in body → Should return
process_body - Email with order in body only → Should return
process_body - Email with both order PDF and order in body → Should return
process_both - Newsletter email → Should return
skip - Email with non-order PDF (invoice) → Should detect and route appropriately
- Triage API failure → Should fallback to current logic
Files Created/Modified for Triage
Section titled “Files Created/Modified for Triage”New Files:
- ✅
packages/pdf-shared/pdf_shared/utils/email_triage.py- Triage function using Gemini LLM (with robust error handling and Langfuse integration) - ✅
apps/email-api/src/utils/attachment-preview.ts- Attachment metadata utilities - ✅
apps/email-api/src/types/triage.ts- TypeScript types for triage
Modified Files:
- ✅
apps/pdf-api/main.py- Added triage endpoint - ✅
apps/email-api/src/services/webhook-service.ts- Added triage integration - ✅
apps/email-api/src/routes/polling.ts- Added triage to polling flow - ✅
apps/email-api/src/routes/webhook.ts- Updated to pass triage configuration - ✅
apps/email-api/src/adapters/gmail-adapter.ts- Added partial download methods (for future use, currently not used by triage)
2025-12-01 Update: The LLM observability helper
log_llm_interactionwas refactored to a new signature. The triage implementation inemail_triage.pyhas been updated accordingly so that token usage and latency are now passed via themetadatafield instead of individual arguments. No behavioral changes to triage decisions or the overall flow were introduced by this refactor.
End-to-End Flow: From Incoming Email to Generated Order
Section titled “End-to-End Flow: From Incoming Email to Generated Order”This section summarizes the complete processing flow from the moment an email arrives until one or more orders are generated and ready for ERP submission.
-
Email arrival & normalization (email-api)
- Gmail sends a Pub/Sub/webhook notification with a
historyIdtoemail-api(/api/webhook/gmail). email-apiuses thehistoryIdto fetch the actual Gmail messages and obtain theirmessageIds.- The Gmail adapter normalizes each message into a
NormalizedEmailobject:- Basic fields:
from,to[],subject,textBody,htmlBody,receivedAt. - Attachments metadata:
attachments[](filename, MIME type, size, Gmail attachment id). - Metadata for filtering: newsletter/auto-reply headers (e.g.
List-Id,Auto-Submitted,Precedence,Return-Path, etc.).
- Basic fields:
- Gmail sends a Pub/Sub/webhook notification with a
-
Alias resolution, deduplication & email event creation
email-apichecks an in-memory cache and the database to avoid re-processing the samemessageId(deduplication).- The subaddress is extracted from the
toaddress (e.g.demo_demouser@...) and used to look up an email alias record:- Contains the ERP connection (
erpConnectionId) and organization context (clerkOrganizationId).
- Contains the ERP connection (
- An
email_eventrecord is created or updated withstatus: processing, linked to the alias and the message Id.
-
LLM triage decision (webapp + pdf-api)
email-apiprepares a triage payload:email: subject, sender, recipients, bodies (textBody/htmlBody),receivedAt.attachments: list of attachment previews (filename, MIME type, size only; no binary).
email-apicalls the webapp triage proxy:POST /api/email/triage.- The webapp validates the payload and forwards it to pdf-api at
POST /email/triage, including the service token. - In
pdf-api,perform_email_triage:- Builds a detailed prompt from the email + attachment metadata.
- Calls Gemini (
gemini-3-flash-preview) for a structured JSON decision. - Parses and validates the JSON robustly, logging token usage and latency.
- Returns a
TriageResponse:decision:process_attachments,process_body,process_both, orskip.confidence,reasoning, and optionalattachmentAnalysis[].
- The webapp returns this
TriageResponseback toemail-api.
-
Applying the triage decision in email-api
email-apiinspectsTriageResponse.decision:skip: mark theemail_eventas completed without creating documents.process_attachments: process only attachments.process_body: process only the email body.process_both: process both attachments and body in sequence.
- If the triage request fails (network/LLM error), the system falls back to the default heuristic:
- If there are attachments → treat them as the primary source.
- If there are no attachments → attempt body processing using pattern detection.
-
Processing attachments (when selected by triage)
email-apidownloads each selected attachment from Gmail using its attachment id.- For each attachment:
- Calls the webapp upload endpoint
POST /api/pdf-documentswith:- The attachment binary.
erpConnectionId,emailMessageId,userId(derived from alias), andemailEventId.
- The webapp:
- Uploads the file to R2 storage under a key like
Demo ERP-external-orders/<...>.pdf. - Creates a
pdf_documentsrow with:filename,file_type,source='external',status='pending',r2_key,erp_connection_id,email_event_id.
- Immediately calls pdf-api:
POST /pdf-documents/{document_id}/processto start processing the document.
- Uploads the file to R2 storage under a key like
- Calls the webapp upload endpoint
-
Processing email body (when selected by triage)
email-apicallsprocessEmailBodyinWebhookService:- First uses
hasOrderContentInBody(email):- Applies
shouldFilterEmail(email)to skip newsletters, auto-replies, and obvious non-order emails. - Scans for common order patterns: PO numbers, quantities, prices, shipping addresses, SKUs, etc.
- Applies
- If no order patterns are detected, the function logs and returns without creating any document.
- If order content is detected:
extractOrderContent(email)builds a clean, normalized text representation of the body:- Prefers
textBody, otherwise cleanshtmlBody(strip tags, decode entities, normalize newlines, remove control chars).
- Prefers
email-apicalls the webapp PDF generator:POST /api/orders/generate-email-body-pdfwith:content,from,to[],subject,date, andmessageId.
- The webapp uses
pdfkitto create a PDF:- Header with email metadata (from/to/subject/date).
- Cleaned body content.
- Footer with the
messageIdfor traceability.
- The generated PDF is returned as binary to
email-api, which wraps it in aFileobject:- Filename pattern:
email-body-<messageId>.pdf.
- Filename pattern:
processEmailBodythen calls the same upload flow as for attachments:POST /api/pdf-documentsin the webapp.- This creates a
pdf_documentsrecord and triggersPOST /pdf-documents/{id}/processin pdf-api.
- First uses
-
Asynchronous document processing in pdf-api + pdf-worker
- In
pdf-api,POST /pdf-documents/{id}/processenqueues a Celery taskprocess_document_taskin pdf-worker`:- The task receives
document_idandr2_key.
- The task receives
- In pdf-worker:
- Downloads the file from R2 using
r2_key. - If the file is Excel/CSV, converts it to tabular text; if PDF, may split into chunks/pages.
- For each chunk:
- Builds a structured prompt for order extraction (PO, items, quantities, prices, addresses, etc.).
- Calls Gemini to obtain structured JSON with one or more orders.
- Parses and validates the JSON with robust error handling and recovery.
- Consolidates all chunk results into a single set of logical orders.
- Validates and normalizes the orders:
- Customer info, addresses, totals, line items, and optionally ERP cross-checking.
- Writes the results to the database:
extracted_orders(order headers).extracted_order_items(line items).
- Updates the corresponding
pdf_documents.statustocompletedorfailed.
- Downloads the file from R2 using
- In
-
Email event completion & UI exposure
- Once all documents associated with an
email_eventare processed:- The
email_eventis marked ascompleted(or failed if appropriate).
- The
- In the webapp UI:
- The Inbox, dashboard, and email detail pages show the related
pdf_documentscount. - Documents generated from the email body are visually marked with an “Email Body” badge (based on the
email-body-*filename). - Users can open each document, review the AI-extracted order (
extracted_orders+extracted_order_items), edit it, and then submit it to the ERP system through the existing order submission flows.
- The Inbox, dashboard, and email detail pages show the related
- Once all documents associated with an
Rollout Strategy
Section titled “Rollout Strategy”- Phase 1: Implement triage function and pdf-api endpoint ✅
- Phase 2: Integrate triage into email processing flow ✅
- Phase 3: Monitor accuracy and performance
- Phase 4: Optimize based on real-world usage data
Future Enhancements
Section titled “Future Enhancements”- Learn from user feedback to improve triage accuracy
- Cache common patterns to reduce LLM calls
- A/B testing different prompt strategies
- Per-organization triage customization
- Enhanced attachment analysis (currently uses metadata only)