FeaturesTemplatesShowcaseTownie
AI
BlogDocsPricing
Log inSign up
project logo

lightweight

findings

Remix of lightweight/findings-base
Public
Like
findings
Home
Code
7
_townie
7
backend
8
frontend
1
shared
1
.vtignore
deno.json
H
main.tsx
Branches
2
Pull requests
Remixes
History
Environment variables
8
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
_townie
/
email-PDF-attachments.md
Code
/
_townie
/
email-PDF-attachments.md
Search
8/28/2025
email-PDF-attachments.md

Complete Instructions: Email Handler for PDF Attachments to Notion

Objective

Create an email trigger that processes PDF attachments from incoming emails and uploads them to a Notion database, with each PDF getting its own page. Use Notion's official 3-step file upload API and maintain proper separation of concerns throughout the Val architecture.

Prerequisites

  • FINDINGS_TRANSCRIPTS_DB_ID environment variable set to target Notion database ID
  • NOTION_API_KEY environment variable set for Notion API authentication
  • Notion database with properties: Name (title), Filename (rich text), PDF (files), Status (select)

Implementation Steps

1. Create Email Types (/backend/types/email.types.ts)

/**
 * Email Processing Type Definitions
 * TypeScript interfaces for email attachment processing
 */

// Val Town's actual Email interface
export interface Email {
  from: string;
  to: string[];
  cc?: string | string[];
  bcc?: string | string[];
  subject?: string;
  text?: string;
  html?: string;
  attachments?: File[];  // File[] array, NOT custom objects
}

export interface ProcessEmailAttachmentsRequest {
  attachments: File[];  // Use actual File objects
  databaseId: string;
  pdfPropertyName?: string;
}

export interface AttachmentProcessingResult {
  filename: string;
  pageCreated: boolean;
  pageId?: string;
  fileUploaded: boolean;
  uploadId?: string;
  error?: string;
}

export interface ProcessEmailAttachmentsResponse {
  success: boolean;
  totalAttachments: number;
  processedAttachments: number;
  successfulUploads: number;
  results: AttachmentProcessingResult[];
  errors: string[];
  timestamp: string;
}

export interface NotionFileUploadObject {
  id: string;
  upload_url: string;
  expiry_time: string;
}

2. Add Notion File Upload Services (/backend/services/notion.service.ts)

Add these three functions to the existing service file:

/**
 * Step 1: Create File Upload Object
 * Creates an upload object in Notion and returns id and upload_url
 */
export async function createNotionFileUpload() {
  try {
    const apiKey = Deno.env.get("NOTION_API_KEY");
    
    const createUploadResponse = await fetch('https://api.notion.com/v1/file_uploads', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
        'Notion-Version': '2022-06-28'
      },
      body: JSON.stringify({})
    });

    if (!createUploadResponse.ok) {
      const errorText = await createUploadResponse.text();
      throw new Error(`HTTP ${createUploadResponse.status}: ${errorText}`);
    }

    const uploadObject = await createUploadResponse.json();
    
    return {
      success: true,
      data: uploadObject,
      timestamp: new Date().toISOString(),
    };
  } catch (error) {
    return {
      success: false,
      error: error.message,
      timestamp: new Date().toISOString(),
    };
  }
}

/**
 * Step 2: Upload File Contents
 * Uploads the actual file content to Notion's upload URL using FormData
 */
export async function uploadFileToNotionUrl(uploadUrl: string, pdfFile: File) {
  try {
    const apiKey = Deno.env.get("NOTION_API_KEY");
    
    const formData = new FormData();
    formData.append('file', pdfFile);
    
    const uploadResponse = await fetch(uploadUrl, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Notion-Version': '2022-06-28'
        // DO NOT set Content-Type - let FormData handle it
      },
      body: formData
    });

    if (!uploadResponse.ok) {
      const errorText = await uploadResponse.text();
      throw new Error(`HTTP ${uploadResponse.status}: ${errorText}`);
    }

    const uploadResult = await uploadResponse.json();
    
    return {
      success: true,
      data: uploadResult,
      timestamp: new Date().toISOString(),
    };
  } catch (error) {
    return {
      success: false,
      error: error.message,
      timestamp: new Date().toISOString(),
    };
  }
}

/**
 * Step 3: Attach File to Page
 * Attaches the uploaded file to a Notion page property using file_upload type
 */
export async function attachFileUploadToPage(pageId: string, propertyName: string, uploadObjectId: string, fileName: string) {
  try {
    const apiKey = Deno.env.get("NOTION_API_KEY");
    
    const attachResponse = await fetch(`https://api.notion.com/v1/pages/${pageId}`, {
      method: 'PATCH',
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        'Notion-Version': '2022-06-28',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        properties: {
          [propertyName]: {
            files: [{
              name: fileName,
              type: "file_upload",  // NOT "file" or "external"
              file_upload: {
                id: uploadObjectId  // ID from Step 1
              }
            }]
          }
        }
      })
    });

    if (!attachResponse.ok) {
      const errorText = await attachResponse.text();
      throw new Error(`HTTP ${attachResponse.status}: ${errorText}`);
    }

    const attachResult = await attachResponse.json();
    
    return {
      success: true,
      data: attachResult,
      timestamp: new Date().toISOString(),
    };
  } catch (error) {
    return {
      success: false,
      error: error.message,
      timestamp: new Date().toISOString(),
    };
  }
}

3. Create Email Utilities (/backend/utils/email.utils.ts)

/**
 * Email Utility Functions
 * Utility functions for processing email data and attachments
 */

/**
 * Extract interviewee name from filename
 * Looks for content in parentheses, falls back to filename without extension
 * 
 * @param filename - The original filename from email attachment
 * @returns Extracted interviewee name or filename without extension
 * 
 * @example
 * extractIntervieweeName("Dealfront discussion (Anna Moch) - 2025_08_04.pdf")
 * // Returns: "Anna Moch"
 * 
 * extractIntervieweeName("meeting-notes.pdf")
 * // Returns: "meeting-notes"
 */
export function extractIntervieweeName(filename: string): string {
  // Look for content in parentheses
  const match = filename.match(/\(([^)]+)\)/);
  if (match) {
    return match[1].trim(); // Return content inside first parentheses
  }
  
  // Fallback: use filename without extension
  return filename.replace(/\.[^/.]+$/, "").trim();
}

4. Create Email Controller (/backend/controllers/emails.controller.ts)

/**
 * Emails Controller
 * Business logic for processing email PDF attachments to Notion
 */

import {
  createPageInDatabase,
  createNotionFileUpload,
  uploadFileToNotionUrl,
  attachFileUploadToPage,
  updatePageProperties,
} from "../services/notion.service.ts";
import { extractIntervieweeName } from "../utils/email.utils.ts";
import type {
  ProcessEmailAttachmentsRequest,
  ProcessEmailAttachmentsResponse,
  AttachmentProcessingResult,
  NotionFileUploadObject,
} from "../types/email.types.ts";

/**
 * Main function to process email PDF attachments
 * Creates child pages in Notion database and uploads PDFs using 3-step process
 */
export async function processEmailPdfAttachments(
  request: ProcessEmailAttachmentsRequest
): Promise<ProcessEmailAttachmentsResponse> {
  const timestamp = new Date().toISOString();
  const results: AttachmentProcessingResult[] = [];
  const errors: string[] = [];
  const pdfPropertyName = request.pdfPropertyName || "PDF";

  console.log(`📧 Processing ${request.attachments.length} email attachments`);

  // Filter for PDF attachments only using File object properties
  const pdfAttachments = request.attachments.filter(
    file => file.name.toLowerCase().endsWith('.pdf') || 
            file.type.includes('pdf')
  );

  console.log(`📄 Found ${pdfAttachments.length} PDF attachments`);

  if (pdfAttachments.length === 0) {
    return {
      success: true,
      totalAttachments: request.attachments.length,
      processedAttachments: 0,
      successfulUploads: 0,
      results: [],
      errors: ["No PDF attachments found"],
      timestamp,
    };
  }

  // Process each PDF attachment - each gets its own page
  for (const file of pdfAttachments) {
    const result = await processSinglePdfFile(
      file,
      request.databaseId,
      pdfPropertyName
    );
    
    results.push(result);
    
    if (result.error) {
      errors.push(`${file.name}: ${result.error}`);
    }

    // Small delay between attachments to avoid rate limiting
    await new Promise(resolve => setTimeout(resolve, 500));
  }

  const successfulUploads = results.filter(r => r.fileUploaded).length;
  const processedAttachments = results.filter(r => r.pageCreated).length;

  console.log(`📊 Processing complete: ${processedAttachments}/${pdfAttachments.length} pages created, ${successfulUploads}/${pdfAttachments.length} files uploaded`);

  return {
    success: errors.length === 0,
    totalAttachments: request.attachments.length,
    processedAttachments,
    successfulUploads,
    results,
    errors,
    timestamp,
  };
}

/**
 * Process a single PDF File using two-phase approach
 * Phase 1: Create page in database
 * Phase 2: Upload file using 3-step Notion API
 */
async function processSinglePdfFile(
  file: File,
  databaseId: string,
  pdfPropertyName: string
): Promise<AttachmentProcessingResult> {
  const result: AttachmentProcessingResult = {
    filename: file.name,
    pageCreated: false,
    fileUploaded: false,
  };

  try {
    console.log(`📄 Processing PDF: ${file.name}`);
    console.log(`   - File type: ${file.type}`);
    console.log(`   - File size: ${file.size} bytes`);

    // Extract interviewee name from filename
    const intervieweeName = extractIntervieweeName(file.name);
    console.log(`   - Extracted interviewee name: "${intervieweeName}"`);

    // Phase 1: Create page in database
    const pageProperties = {
      "Name": {
        title: [
          {
            text: {
              content: intervieweeName
            }
          }
        ]
      },
      "Filename": {
        rich_text: [
          {
            text: {
              content: file.name
            }
          }
        ]
      },
      "Status": {
        select: {
          name: "Uploading..."
        }
      }
    };

    const pageResult = await createPageInDatabase(databaseId, pageProperties);
    
    if (!pageResult.success) {
      result.error = `Failed to create page: ${pageResult.error}`;
      return result;
    }

    result.pageCreated = true;
    result.pageId = pageResult.data.id;
    console.log(`✅ Page created: ${result.pageId}`);

    // Phase 2: Upload file using 3-step process
    const uploadResult = await uploadPdfToNotionPage(
      result.pageId,
      file,
      pdfPropertyName
    );

    if (uploadResult.success) {
      result.fileUploaded = true;
      result.uploadId = uploadResult.uploadId;
      console.log(`✅ File uploaded successfully: ${file.name}`);
      
      // Update status to "Uploaded"
      const statusUpdateResult = await updatePageProperties(result.pageId, {
        "Status": {
          select: { name: "Uploaded" }
        }
      });
      
      if (statusUpdateResult.success) {
        console.log(`✅ Status updated to "Uploaded" for ${file.name}`);
      } else {
        console.log(`⚠️ Failed to update status to "Uploaded": ${statusUpdateResult.error}`);
      }
      
    } else {
      result.error = `File upload failed: ${uploadResult.error}`;
      console.log(`⚠️ File upload failed for ${file.name}, updating status to "Upload error"`);
      
      // Update status to "Upload error"
      const statusUpdateResult = await updatePageProperties(result.pageId, {
        "Status": {
          select: { name: "Upload error" }
        }
      });
      
      if (statusUpdateResult.success) {
        console.log(`✅ Status updated to "Upload error" for ${file.name}`);
      } else {
        console.log(`⚠️ Failed to update status to "Upload error": ${statusUpdateResult.error}`);
      }
    }

  } catch (error) {
    result.error = `Unexpected error: ${error.message}`;
    console.error(`❌ Error processing ${file.name}:`, error);
  }

  return result;
}

/**
 * Upload PDF to Notion page using exact 3-step file upload process
 */
async function uploadPdfToNotionPage(
  pageId: string,
  pdfFile: File,
  propertyName: string
): Promise<{ success: boolean; uploadId?: string; error?: string }> {
  try {
    // Validate file before upload
    console.log(`📋 Validating file: ${pdfFile.name}`);
    console.log(`   - Content type: ${pdfFile.type}`);
    console.log(`   - File size: ${pdfFile.size} bytes`);
    
    if (pdfFile.size === 0) {
      return { success: false, error: 'File is empty' };
    }
    
    if (pdfFile.size > 50 * 1024 * 1024) { // 50MB limit
      return { success: false, error: 'File too large (>50MB)' };
    }

    // Step 1: Create File Upload Object
    console.log(`📤 Step 1: Creating upload object for ${pdfFile.name}`);
    const createUploadResult = await createNotionFileUpload();
    
    if (!createUploadResult.success) {
      return { success: false, error: `Step 1 failed: ${createUploadResult.error}` };
    }

    const uploadObject = createUploadResult.data;
    console.log(`✅ Step 1 complete: Upload object created with ID ${uploadObject.id}`);

    // Step 2: Upload File Contents
    console.log(`📤 Step 2: Uploading file contents for ${pdfFile.name}`);
    
    const uploadResult = await uploadFileToNotionUrl(uploadObject.upload_url, pdfFile);
    
    if (!uploadResult.success) {
      return { success: false, error: `Step 2 failed: ${uploadResult.error}` };
    }

    console.log(`✅ Step 2 complete: File contents uploaded`);
    console.log(`   - Upload status: ${uploadResult.data.status}`);

    // Step 3: Attach File to Page
    console.log(`📤 Step 3: Attaching file to page ${pageId}`);
    const attachResult = await attachFileUploadToPage(
      pageId,
      propertyName,
      uploadObject.id,  // Use the upload object ID from Step 1
      pdfFile.name
    );

    if (!attachResult.success) {
      return { success: false, error: `Step 3 failed: ${attachResult.error}` };
    }

    console.log(`✅ Step 3 complete: File attached to page with type "file_upload"`);
    console.log(`   - Property updated: ${propertyName}`);

    return { success: true, uploadId: uploadObject.id };

  } catch (error) {
    console.error(`❌ Upload process error:`, error);
    return { success: false, error: `Upload process failed: ${error.message}` };
  }
}

5. Create Email Handler (/backend/emails/pdf-attachments.ts)

/**
 * Email Handler: PDF Attachments to Notion
 * Processes incoming emails with PDF attachments and saves them to Notion database
 * Each PDF attachment gets its own page in the database
 */

import { processEmailPdfAttachments } from "../controllers/emails.controller.ts";
import type { Email } from "../types/email.types.ts";

export default async function(email: Email) {
  try {
    console.log(`📧 Received email: ${email.subject || 'No Subject'}`);
    console.log(`📧 From: ${email.from}`);
    console.log(`📧 Attachments: ${email.attachments?.length || 0}`);

    // Get database ID from environment
    const databaseId = Deno.env.get('FINDINGS_TRANSCRIPTS_DB_ID');
    if (!databaseId) {
      console.error('❌ FINDINGS_TRANSCRIPTS_DB_ID environment variable not set');
      return {
        success: false,
        error: 'Database ID not configured',
        timestamp: new Date().toISOString(),
      };
    }

    // Check if email has attachments
    if (!email.attachments || email.attachments.length === 0) {
      console.log('📧 No attachments found in email');
      return {
        success: true,
        message: 'No attachments to process',
        timestamp: new Date().toISOString(),
      };
    }

    // Log attachment details for debugging
    console.log(`📎 Processing ${email.attachments.length} attachments:`);
    email.attachments.forEach((file, index) => {
      console.log(`   ${index + 1}. ${file.name} (${file.type}, ${file.size} bytes)`);
    });

    // Filter for PDF attachments
    const pdfAttachments = email.attachments.filter(
      file => file.name.toLowerCase().endsWith('.pdf') || 
              file.type.includes('pdf')
    );

    console.log(`📄 Found ${pdfAttachments.length} PDF attachments`);

    if (pdfAttachments.length === 0) {
      console.log('📧 No PDF attachments found in email');
      return {
        success: true,
        message: 'No PDF attachments to process',
        timestamp: new Date().toISOString(),
      };
    }

    // Process attachments through controller
    const result = await processEmailPdfAttachments({
      attachments: email.attachments, // Pass all attachments, controller will filter PDFs
      databaseId,
      pdfPropertyName: 'PDF',
    });

    // Log results
    console.log(`📊 Email processing complete:`);
    console.log(`   - Total attachments: ${result.totalAttachments}`);
    console.log(`   - PDF attachments: ${pdfAttachments.length}`);
    console.log(`   - Pages created: ${result.processedAttachments}`);
    console.log(`   - Files uploaded: ${result.successfulUploads}`);
    
    if (result.errors.length > 0) {
      console.log(`   - Errors: ${result.errors.length}`);
      result.errors.forEach(error => console.log(`     • ${error}`));
    }

    return {
      success: result.success,
      message: `Processed ${result.processedAttachments} PDF attachments, ${result.successfulUploads} uploaded successfully`,
      data: {
        totalAttachments: result.totalAttachments,
        pdfAttachments: pdfAttachments.length,
        processedAttachments: result.processedAttachments,
        successfulUploads: result.successfulUploads,
        results: result.results,
      },
      errors: result.errors,
      timestamp: result.timestamp,
    };

  } catch (error) {
    console.error('❌ Email handler error:', error);
    return {
      success: false,
      error: error.message,
      timestamp: new Date().toISOString(),
    };
  }
}

6. Set Email Trigger

Use change_val_type tool to configure /backend/emails/pdf-attachments.ts as email trigger.

7. Create Directory READMEs

Create /backend/emails/README.md:

# Emails

Email trigger handlers for processing incoming emails.

## Files

- `pdf-attachments.ts` - Processes emails with PDF attachments and uploads them to Notion database pages

## Architecture

Email handlers follow the same separation of concerns as other parts of the application:

- **Email Handlers**: Extract data from email objects and call controllers
- **Controllers**: Business logic for processing email data
- **Services**: Pure API calls to external systems (Notion, etc.)

## PDF Attachments Handler

The `pdf-attachments.ts` handler:

1. Receives emails with PDF attachments
2. Creates child pages in the Notion database specified by `FINDINGS_TRANSCRIPTS_DB_ID`
3. Uploads each PDF to its own page using Notion's 3-step file upload API
4. Continues processing even if individual uploads fail
5. Returns detailed results for monitoring

### Page Properties

Each PDF attachment creates a page with:

- **Name**: Extracted interviewee name from filename (content in parentheses)
- **Filename**: Complete original filename from email attachment
- **PDF**: Uploaded file using Notion's file upload API
- **Status**: Upload progress tracking

### Status Progression

- **"Uploading..."**: Initial status when page is created
- **"Uploaded"**: File successfully uploaded and attached to page
- **"Upload error"**: File upload failed (page still exists for retry)

### Filename Processing

The handler extracts interviewee names using pattern matching:

- **Pattern**: `(Name)` - Content inside first parentheses becomes the page title
- **Example**: `"Interview (John Doe) - 2025_01_01.pdf"` → Name: `"John Doe"`
- **Fallback**: If no parentheses found, uses filename without extension

### Environment Variables

- `FINDINGS_TRANSCRIPTS_DB_ID` - Notion database ID where pages will be created
- `NOTION_API_KEY` - Notion API key for authentication

### Process Flow

1. **Email Reception**: Handler receives email with attachments
2. **PDF Filtering**: Only processes PDF attachments (by extension or MIME type)
3. **Page Creation**: Creates a new page in the target database for each PDF
4. **File Upload**: Uses Notion's 3-step upload process:
   - Create upload object
   - Upload file contents
   - Attach file to page
5. **Status Updates**: Updates page status based on upload success/failure
6. **Error Handling**: Continues processing even if individual steps fail
7. **Response**: Returns detailed processing results

8. Update Existing READMEs

Update /backend/README.md to include /emails directory:

- `/emails` - Email trigger handlers for processing incoming emails

Update /backend/controllers/README.md to include emails controller:

- `emails.controller.ts` - Handles email PDF attachment processing workflow
  - **PDF Processing**: Processes each PDF attachment as separate database page
  - **Filename Parsing**: Extracts interviewee names from filenames using parentheses pattern
  - **Status Tracking**: Manages upload status progression ("Uploading..." → "Uploaded" → "Upload error")
  - **3-Step Upload**: Orchestrates Notion's file upload process (create → upload → attach)

Update /backend/services/README.md to include file upload functions:

- **File Upload API**: 3-step file upload process implementation
  - `createNotionFileUpload()` - Step 1: Create upload object
  - `uploadFileToNotionUrl()` - Step 2: Upload file contents via FormData
  - `attachFileUploadToPage()` - Step 3: Attach file to page with "file_upload" type

Update /backend/types/README.md to include email types:

- `email.types.ts` - Email processing and attachment handling types
  - **Email Interface**: Val Town's standard Email interface with File[] attachments
  - **Processing Types**: Request/response interfaces for email attachment workflows
  - **Result Types**: Detailed processing results and error tracking

Update /backend/utils/README.md to include email utils:

- `email.utils.ts` - Email processing utilities (filename parsing, interviewee name extraction)

9. Update Notion Types

Add file property type to /backend/types/notion.types.ts:

export interface NotionFileProperty {
  files: Array<{
    name: string;
    type: "file_upload";
    file_upload: {
      id: string;
    };
  }>;
}

And update the main interface:

export interface NotionProperties {
  [key: string]: NotionDateProperty | NotionSelectProperty | NotionRichTextProperty | NotionNumberProperty | NotionFileProperty;
}

Critical Implementation Details

Val Town Email Interface

  • MUST USE File[] for attachments, NOT custom objects
  • Use file.name, file.type, file.size properties
  • DO NOT use filename, contentType, content properties

Notion 3-Step Upload Process

  • Step 1: Create upload object with empty JSON body
  • Step 2: Use FormData with File object, DO NOT set Content-Type header
  • Step 3: Use type: "file_upload" with upload object ID

Separation of Concerns

  • Services: Pure API calls only, return consistent format
  • Controllers: Business logic orchestration, coordinate services
  • Utils: Pure utility functions, domain-organized
  • Handlers: Request/response handling, call controllers only

Status Management

  • "Uploading...": Set on page creation
  • "Uploaded": Set after successful 3-step upload
  • "Upload error": Set if upload fails (page still exists)

Error Handling

  • Continue processing even if individual uploads fail
  • Log detailed information for debugging
  • Update status appropriately for failed uploads
  • Return comprehensive results for monitoring

Expected Behavior

When email received with PDF attachments:

  1. Creates separate Notion page for each PDF
  2. Extracts interviewee name from filename for page title
  3. Stores complete filename in Filename property
  4. Uploads PDF using official Notion API
  5. Updates status based on upload success/failure
  6. Returns detailed processing results
  7. Continues processing even if individual files fail

This implementation maintains proper separation of concerns, uses Val Town's actual email interface, implements Notion's official file upload API correctly, and provides comprehensive error handling and status tracking.

FeaturesVersion controlCode intelligenceCLI
Use cases
TeamsAI agentsSlackGTM
ExploreDocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareersBrandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Terms of usePrivacy policyAbuse contact
© 2025 Val Town, Inc.