Skip to content

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-api was still the Gmail intake path. The canonical intake/runtime path is now webapp + Temporal. Read apps/email-api references below as migration history / legacy architecture unless a section explicitly says otherwise.

Current entrypoints:

  • Gmail intake: per-connection Temporal polling (ScheduledEmailPullDispatcherpoll_gmail_for_connectionEmailProcessingWorkflow), driven by the email_pull_connections table (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.)

This feature has been fully implemented and is ready for production use.

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 in email_pull_connections and connected via the webapp Settings → Email Connections flow.

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.

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.

What was added in the original rollout:

  1. Email order detection (pattern matching in email body)
  2. PDF generation (convert email text to PDF with metadata)
  3. 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)

For the current runtime path, start with:

  • apps/temporal-worker/workflows/email_pull_dispatcher.py (ScheduledEmailPullDispatcherpoll_gmail_for_connection)
  • apps/webapp/src/pages/api/orders/generate-email-body-pdf.ts
  • apps/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.


  1. Gmail event arrives → webapp (Temporal polling route)
  2. Webapp normalizes the event and starts Temporal email processing
  3. Gmail message + alias resolution happen in the active email-processing path
  4. Attachments / generated documents are uploaded and linked to records
  5. Existing extraction pipeline processes the resulting documents and saves orders
  • If no attachments exist, the flow stops
  • Emails with orders in the body are not processed

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:

  1. Detect when no attachments exist but body contains order content
  2. Extract content from email (textBody or htmlBody)
  3. Generate a PDF from the email text content
  4. Process with Gemini AI using the exact same pipeline as regular PDFs
  5. Extract and validate order data as usual

Phase 1: Modify Email-API to Detect and Process Email Bodies

Section titled “Phase 1: Modify Email-API to Detect and Process Email Bodies”

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(/&nbsp;/gi, ' ') // Decode HTML entities
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/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, and Return-Path headers
  • Conservative Approach: Designed to avoid false positives that would filter legitimate order emails
  • Integrated Detection: hasOrderContentInBody() calls shouldFilterEmail() 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 indicators
  • Auto-Submitted - Auto-reply indicator
  • Precedence - Bulk/list indicators
  • Return-Path - Automated message detection
  • X-Auto-Response-Suppress, X-Mailer, Content-Type - Additional metadata

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:

Terminal window
pnpm --filter webapp add pdfkit @types/pdfkit

API 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 .pdf extension (standard format)
  • Uploads using existing uploadAttachmentToWebapp method
  • Uses fileType: 'pdf' - no special type needed!
  • Follows same authentication and error handling as attachments
  • Zero changes needed downstream - pdf-worker handles it normally

Files:

  • apps/email-api/src/routes/polling.ts
  • apps/email-api/src/routes/webhook.ts

Modify logic to attempt body processing if no attachments:

// After processing attachments
if (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:

  1. Email has both attachments AND order in body → Process attachments only (primary source)
  2. Email has no attachments and no order patterns → Skip processing, log info
  3. Email is filtered (newsletter/auto-reply) → Skip processing, no log needed (filtered before detection)
  4. Email body detection fails → Catch error, log, continue
  5. PDF generation fails → Catch error, log, mark email event appropriately

Phase 2: Update Database SchemaNOT 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-WorkerMINIMAL/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!

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 construction
if 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_suffix

This 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”

Files Updated:

  • 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 added badge
  • apps/webapp/src/pages/inbox.astro - Updated document count query

Changes Made:

  1. Document Count Fix:

    • Changed from using emailEvents.attachmentCount (only counts original attachments)
    • Now counts actual pdfDocuments related to each email
    • Shows correct count for both attachment-based and email-body-generated PDFs
  2. 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>
)}

No changes needed! The existing PDF preview component will work automatically since these are standard PDFs.

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
}

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.ts
  • apps/webapp/src/pages/api/orders/generate-email-body-pdf.ts

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

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.astro
  • apps/webapp/src/pages/orders/email/[emailId].astro

Problems:

  • APIRoute not found in ‘astro’ module
  • ArrayBuffer not assignable to File constructor

Solutions:

  • Defined APIRoute type locally instead of importing
  • Convert ArrayBuffer to Uint8Array for File constructor

Files:

  • apps/webapp/src/pages/api/orders/generate-email-body-pdf.ts
  • apps/email-api/src/services/webhook-service.ts

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 - Added shouldFilterEmail() function
  • apps/email-api/src/adapters/gmail-adapter.ts - Enhanced to store headers in metadata

Simple Order:

PO #: PO-2024-12345
Item: SKU-001
Quantity: 25
Price: $45.00
Ship to:
123 Main Street
City, ST 12345

Complex Order:

Purchase Order: ABC-123
Date: 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 Corp
4567 Commerce Boulevard
Houston, TX 77001
  • 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

  • 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

  1. Enhanced Pattern Detection:

    • Machine learning-based order detection
    • Custom pattern configuration per organization
    • Support for more languages
  2. PDF Generation Optimizations:

    • Better HTML parsing (use library like html-to-text)
    • Preserve formatting (tables, lists)
    • Add images from HTML emails
  3. UI Enhancements:

    • Filter by document source (attachment vs email body)
    • Bulk reprocess email bodies
    • Preview email body before PDF generation
  4. Error Handling:

    • Retry mechanism for failed PDF generation
    • Better error messages for users
    • Fallback to plain text if PDF generation fails
  5. 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

  • 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
  • apps/email-api/src/services/webhook-service.ts - Added processEmailBody() method and performTriage() 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 - Added shouldFilterEmail() 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 - Added pdfkit and @types/pdfkit dependencies
  • apps/email-api/package.json - Removed pdfkit (moved to webapp)
  • pdfkit@^0.15.0 (webapp)
  • @types/pdfkit@^0.13.5 (webapp)
  • pdfkit (from email-api - incompatible with Cloudflare Workers)

All phases have been successfully implemented and tested. The feature is production-ready and fully integrated into the existing order processing pipeline.


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.

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)

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 attachments
  • skip - No order content found, or clearly a non-order email (newsletter, auto-reply)

Flow:

  1. Email arrives → email-api (webhook/polling)
  2. Triage Step → email-api calls webapp → webapp calls pdf-api → pdf-api uses Gemini LLM to analyze email + attachment metadata
  3. Returns decision with confidence and reasoning
  4. Process according to triage decision
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:#d4edda

Components:

  • 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-api
  • apps/email-api/src/utils/attachment-preview.ts - Attachment metadata utilities
  • apps/email-api/src/services/webhook-service.ts - Triage integration in WebhookService (now calling webapp proxy instead of pdf-api directly)

Located in packages/pdf-shared/pdf_shared/utils/email_triage.py:

  • Uses google.genai (same as pdf-worker)
  • Uses gemini-3-flash-preview model
  • 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

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

Located in apps/webapp/src/pages/api/email/triage.ts:

  • Endpoint: POST /api/email/triage
  • Validates payload shape (matches TriageRequest in email-api)
  • Forwards request to PDF_PROCESSOR_URL/email/triage with PDF_PROCESSOR_TOKEN
  • Returns the pdf-api triage JSON response to callers (e.g. email-api)

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

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

Environment Variables:

  • Webapp:
    • PDF_PROCESSOR_URL - URL of pdf-api service
    • PDF_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)

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
  • 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

Test Cases:

  1. Email with order PDF attachment → Should return process_attachments
  2. Email with image attachment + order in body → Should return process_body
  3. Email with order in body only → Should return process_body
  4. Email with both order PDF and order in body → Should return process_both
  5. Newsletter email → Should return skip
  6. Email with non-order PDF (invoice) → Should detect and route appropriately
  7. Triage API failure → Should fallback to current logic

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_interaction was refactored to a new signature. The triage implementation in email_triage.py has been updated accordingly so that token usage and latency are now passed via the metadata field 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.

  1. Email arrival & normalization (email-api)

    • Gmail sends a Pub/Sub/webhook notification with a historyId to email-api (/api/webhook/gmail).
    • email-api uses the historyId to fetch the actual Gmail messages and obtain their messageIds.
    • The Gmail adapter normalizes each message into a NormalizedEmail object:
      • 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.).
  2. Alias resolution, deduplication & email event creation

    • email-api checks an in-memory cache and the database to avoid re-processing the same messageId (deduplication).
    • The subaddress is extracted from the to address (e.g. demo_demouser@...) and used to look up an email alias record:
      • Contains the ERP connection (erpConnectionId) and organization context (clerkOrganizationId).
    • An email_event record is created or updated with status: processing, linked to the alias and the message Id.
  3. LLM triage decision (webapp + pdf-api)

    • email-api prepares a triage payload:
      • email: subject, sender, recipients, bodies (textBody/htmlBody), receivedAt.
      • attachments: list of attachment previews (filename, MIME type, size only; no binary).
    • email-api calls 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, or skip.
        • confidence, reasoning, and optional attachmentAnalysis[].
    • The webapp returns this TriageResponse back to email-api.
  4. Applying the triage decision in email-api

    • email-api inspects TriageResponse.decision:
      • skip: mark the email_event as 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.
  5. Processing attachments (when selected by triage)

    • email-api downloads each selected attachment from Gmail using its attachment id.
    • For each attachment:
      • Calls the webapp upload endpoint POST /api/pdf-documents with:
        • The attachment binary.
        • erpConnectionId, emailMessageId, userId (derived from alias), and emailEventId.
      • The webapp:
        • Uploads the file to R2 storage under a key like Demo ERP-external-orders/<...>.pdf.
        • Creates a pdf_documents row 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}/process to start processing the document.
  6. Processing email body (when selected by triage)

    • email-api calls processEmailBody in WebhookService:
      • 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.
      • 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 cleans htmlBody (strip tags, decode entities, normalize newlines, remove control chars).
        • email-api calls the webapp PDF generator:
          • POST /api/orders/generate-email-body-pdf with:
            • content, from, to[], subject, date, and messageId.
        • The webapp uses pdfkit to create a PDF:
          • Header with email metadata (from/to/subject/date).
          • Cleaned body content.
          • Footer with the messageId for traceability.
        • The generated PDF is returned as binary to email-api, which wraps it in a File object:
          • Filename pattern: email-body-<messageId>.pdf.
        • processEmailBody then calls the same upload flow as for attachments:
          • POST /api/pdf-documents in the webapp.
          • This creates a pdf_documents record and triggers POST /pdf-documents/{id}/process in pdf-api.
  7. Asynchronous document processing in pdf-api + pdf-worker

    • In pdf-api, POST /pdf-documents/{id}/process enqueues a Celery task process_document_task in pdf-worker`:
      • The task receives document_id and r2_key.
    • 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.status to completed or failed.
  8. Email event completion & UI exposure

    • Once all documents associated with an email_event are processed:
      • The email_event is marked as completed (or failed if appropriate).
    • In the webapp UI:
      • The Inbox, dashboard, and email detail pages show the related pdf_documents count.
      • 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.
  1. Phase 1: Implement triage function and pdf-api endpoint ✅
  2. Phase 2: Integrate triage into email processing flow ✅
  3. Phase 3: Monitor accuracy and performance
  4. Phase 4: Optimize based on real-world usage data
  • 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)