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/**
* 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;
}
File[]
for attachments, NOT custom objectsfile.name
, file.type
, file.size
propertiesfilename
, contentType
, content
propertiestype: "file_upload"
with upload object IDWhen email received with PDF attachments:
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.