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.
FINDINGS_TRANSCRIPTS_DB_ID
environment variable set to target Notion database IDNOTION_API_KEY
environment variable set for Notion API authentication- Notion database with properties: Name (title), Filename (rich text), PDF (files), Status (select)
/**
* 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;
}
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(),
};
}
}
/**
* 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();
}
/**
* 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}` };
}
}
/**
* 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(),
};
}
}
Use change_val_type
tool to configure /backend/emails/pdf-attachments.ts
as email trigger.
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
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)
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;
}
- MUST USE
File[]
for attachments, NOT custom objects - Use
file.name
,file.type
,file.size
properties - DO NOT use
filename
,contentType
,content
properties
- 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
- 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
- "Uploading...": Set on page creation
- "Uploaded": Set after successful 3-step upload
- "Upload error": Set if upload fails (page still exists)
- Continue processing even if individual uploads fail
- Log detailed information for debugging
- Update status appropriately for failed uploads
- Return comprehensive results for monitoring
When email received with PDF attachments:
- Creates separate Notion page for each PDF
- Extracts interviewee name from filename for page title
- Stores complete filename in Filename property
- Uploads PDF using official Notion API
- Updates status based on upload success/failure
- Returns detailed processing results
- 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.