326 lines
9.7 KiB
JavaScript
326 lines
9.7 KiB
JavaScript
|
|
/**
|
||
|
|
* Cloudflare Email Worker for Trips App
|
||
|
|
* Receives emails at travel@quadjourney.com and forwards to trips API for parsing
|
||
|
|
*/
|
||
|
|
|
||
|
|
export default {
|
||
|
|
async fetch(request, env, ctx) {
|
||
|
|
return new Response('Trips Email Worker - Send emails to travel@quadjourney.com', {
|
||
|
|
headers: { 'Content-Type': 'text/plain' }
|
||
|
|
});
|
||
|
|
},
|
||
|
|
|
||
|
|
async email(message, env, ctx) {
|
||
|
|
console.log(`Received email from: ${message.from}`);
|
||
|
|
console.log(`Subject: ${message.headers.get('subject')}`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Read the raw email
|
||
|
|
const rawEmail = await new Response(message.raw).text();
|
||
|
|
|
||
|
|
// Debug: log first 500 chars and search for Content-Type
|
||
|
|
console.log('Raw email preview:', rawEmail.substring(0, 500));
|
||
|
|
|
||
|
|
// Find where Content-Type header is
|
||
|
|
const ctIndex = rawEmail.indexOf('Content-Type:');
|
||
|
|
console.log('Content-Type header at position:', ctIndex);
|
||
|
|
if (ctIndex > 0) {
|
||
|
|
console.log('Content-Type section:', rawEmail.substring(ctIndex, ctIndex + 200));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract email parts
|
||
|
|
const subject = decodeRFC2047(message.headers.get('subject') || '');
|
||
|
|
const from = message.from;
|
||
|
|
|
||
|
|
// Parse email body and attachments
|
||
|
|
const { textBody, htmlBody, attachments } = parseEmail(rawEmail);
|
||
|
|
|
||
|
|
console.log('Parsed text length:', textBody.length);
|
||
|
|
console.log('Parsed html length:', htmlBody.length);
|
||
|
|
|
||
|
|
// Prepare the content for the trips API
|
||
|
|
const bodyContent = textBody || stripHtml(htmlBody) || '(No text content)';
|
||
|
|
const emailContent = `
|
||
|
|
Subject: ${subject}
|
||
|
|
From: ${from}
|
||
|
|
Date: ${new Date().toISOString()}
|
||
|
|
|
||
|
|
${bodyContent}
|
||
|
|
`.trim();
|
||
|
|
|
||
|
|
console.log('Final content length:', emailContent.length);
|
||
|
|
|
||
|
|
// Send to trips API
|
||
|
|
const result = await sendToTripsAPI(env, emailContent, attachments);
|
||
|
|
|
||
|
|
console.log('Parse result:', JSON.stringify(result));
|
||
|
|
|
||
|
|
// Optionally forward the email to a backup address
|
||
|
|
if (env.FORWARD_TO) {
|
||
|
|
await message.forward(env.FORWARD_TO);
|
||
|
|
}
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error processing email:', error);
|
||
|
|
if (env.FORWARD_TO) {
|
||
|
|
await message.forward(env.FORWARD_TO);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Decode RFC 2047 encoded words (=?UTF-8?Q?...?= or =?UTF-8?B?...?=)
|
||
|
|
*/
|
||
|
|
function decodeRFC2047(str) {
|
||
|
|
if (!str) return '';
|
||
|
|
|
||
|
|
// Match encoded words
|
||
|
|
return str.replace(/=\?([^?]+)\?([BQ])\?([^?]*)\?=/gi, (match, charset, encoding, text) => {
|
||
|
|
try {
|
||
|
|
if (encoding.toUpperCase() === 'B') {
|
||
|
|
// Base64
|
||
|
|
return atob(text);
|
||
|
|
} else if (encoding.toUpperCase() === 'Q') {
|
||
|
|
// Quoted-printable
|
||
|
|
return decodeQuotedPrintable(text.replace(/_/g, ' '));
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Failed to decode RFC2047:', e);
|
||
|
|
}
|
||
|
|
return match;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Decode quoted-printable encoding
|
||
|
|
*/
|
||
|
|
function decodeQuotedPrintable(str) {
|
||
|
|
return str
|
||
|
|
.replace(/=\r?\n/g, '') // Remove soft line breaks
|
||
|
|
.replace(/=([0-9A-Fa-f]{2})/g, (match, hex) => {
|
||
|
|
return String.fromCharCode(parseInt(hex, 16));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse email to extract body and attachments (iterative, handles nested multipart)
|
||
|
|
*/
|
||
|
|
function parseEmail(rawEmail) {
|
||
|
|
const result = {
|
||
|
|
textBody: '',
|
||
|
|
htmlBody: '',
|
||
|
|
attachments: []
|
||
|
|
};
|
||
|
|
|
||
|
|
// Normalize line endings to \r\n
|
||
|
|
const normalizedEmail = rawEmail.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n');
|
||
|
|
|
||
|
|
// Collect all parts to process (use a queue instead of recursion)
|
||
|
|
const partsToProcess = [normalizedEmail];
|
||
|
|
const processedBoundaries = new Set();
|
||
|
|
|
||
|
|
while (partsToProcess.length > 0) {
|
||
|
|
const content = partsToProcess.shift();
|
||
|
|
|
||
|
|
// Find boundary - search more of the content since headers can be long
|
||
|
|
// First try to find the Content-Type header with boundary
|
||
|
|
const boundaryMatch = content.match(/Content-Type:.*?boundary="?([^"\r\n;]+)"?/is);
|
||
|
|
|
||
|
|
console.log('Searching for boundary, found:', boundaryMatch ? boundaryMatch[1] : 'NONE');
|
||
|
|
|
||
|
|
if (boundaryMatch) {
|
||
|
|
const boundary = boundaryMatch[1].trim();
|
||
|
|
console.log('Found boundary:', boundary);
|
||
|
|
|
||
|
|
// Skip if we've already processed this boundary
|
||
|
|
if (processedBoundaries.has(boundary)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
processedBoundaries.add(boundary);
|
||
|
|
|
||
|
|
// Split by boundary
|
||
|
|
const parts = content.split('--' + boundary);
|
||
|
|
console.log('Split into', parts.length, 'parts');
|
||
|
|
|
||
|
|
for (let i = 1; i < parts.length; i++) { // Skip first part (preamble)
|
||
|
|
const part = parts[i];
|
||
|
|
if (part.trim() === '--' || part.trim() === '') continue;
|
||
|
|
|
||
|
|
// Check if this part has its own boundary (nested multipart)
|
||
|
|
const partHeader = part.substring(0, 1000);
|
||
|
|
const nestedBoundary = partHeader.match(/boundary="?([^"\r\n;]+)"?/i);
|
||
|
|
if (nestedBoundary) {
|
||
|
|
console.log('Found nested boundary:', nestedBoundary[1]);
|
||
|
|
// Add to queue for processing
|
||
|
|
partsToProcess.push(part);
|
||
|
|
} else if (part.includes('Content-Type:')) {
|
||
|
|
// Extract content from this part
|
||
|
|
extractPartContent(part, result);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Not multipart - try to extract content directly
|
||
|
|
const bodyStart = content.indexOf('\r\n\r\n');
|
||
|
|
if (bodyStart !== -1) {
|
||
|
|
const headers = content.substring(0, bodyStart).toLowerCase();
|
||
|
|
const body = content.substring(bodyStart + 4);
|
||
|
|
|
||
|
|
if (!result.textBody && !headers.includes('content-type:')) {
|
||
|
|
// Plain text email without explicit content-type
|
||
|
|
result.textBody = body;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode quoted-printable in results
|
||
|
|
if (result.textBody) {
|
||
|
|
result.textBody = decodeQuotedPrintable(result.textBody);
|
||
|
|
}
|
||
|
|
if (result.htmlBody) {
|
||
|
|
result.htmlBody = decodeQuotedPrintable(result.htmlBody);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract content from a single MIME part
|
||
|
|
*/
|
||
|
|
function extractPartContent(part, result) {
|
||
|
|
const contentTypeMatch = part.match(/Content-Type:\s*([^\r\n;]+)/i);
|
||
|
|
const contentType = contentTypeMatch ? contentTypeMatch[1].toLowerCase().trim() : '';
|
||
|
|
const contentDisposition = part.match(/Content-Disposition:\s*([^\r\n]+)/i)?.[1] || '';
|
||
|
|
|
||
|
|
console.log('Extracting part with Content-Type:', contentType);
|
||
|
|
|
||
|
|
// Find the body (after double newline)
|
||
|
|
const bodyStart = part.indexOf('\r\n\r\n');
|
||
|
|
if (bodyStart === -1) {
|
||
|
|
console.log('No body separator found in part');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
let body = part.substring(bodyStart + 4);
|
||
|
|
console.log('Body length before trim:', body.length);
|
||
|
|
|
||
|
|
// Remove trailing boundary markers
|
||
|
|
const boundaryIndex = body.indexOf('\r\n--');
|
||
|
|
if (boundaryIndex !== -1) {
|
||
|
|
body = body.substring(0, boundaryIndex);
|
||
|
|
}
|
||
|
|
|
||
|
|
body = body.trim();
|
||
|
|
if (!body) return;
|
||
|
|
|
||
|
|
// Check encoding
|
||
|
|
const isBase64 = part.toLowerCase().includes('content-transfer-encoding: base64');
|
||
|
|
const isQuotedPrintable = part.toLowerCase().includes('content-transfer-encoding: quoted-printable');
|
||
|
|
|
||
|
|
// Decode if needed
|
||
|
|
if (isBase64) {
|
||
|
|
try {
|
||
|
|
body = atob(body.replace(/\s/g, ''));
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Base64 decode failed:', e);
|
||
|
|
}
|
||
|
|
} else if (isQuotedPrintable) {
|
||
|
|
body = decodeQuotedPrintable(body);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Categorize the content
|
||
|
|
if (contentType.includes('text/plain') && !contentDisposition.includes('attachment')) {
|
||
|
|
// Append to existing text (for forwarded emails with multiple text parts)
|
||
|
|
result.textBody += (result.textBody ? '\n\n' : '') + body;
|
||
|
|
} else if (contentType.includes('text/html') && !contentDisposition.includes('attachment')) {
|
||
|
|
result.htmlBody += (result.htmlBody ? '\n\n' : '') + body;
|
||
|
|
} else if (contentDisposition.includes('attachment') ||
|
||
|
|
contentType.includes('application/pdf') ||
|
||
|
|
contentType.includes('image/')) {
|
||
|
|
const filenameMatch = contentDisposition.match(/filename="?([^";\r\n]+)"?/i);
|
||
|
|
const filename = filenameMatch ? filenameMatch[1] : 'attachment';
|
||
|
|
|
||
|
|
result.attachments.push({
|
||
|
|
filename,
|
||
|
|
contentType,
|
||
|
|
data: body.replace(/\s/g, ''),
|
||
|
|
isBase64: isBase64
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Strip HTML tags from content
|
||
|
|
*/
|
||
|
|
function stripHtml(html) {
|
||
|
|
if (!html) return '';
|
||
|
|
return html
|
||
|
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||
|
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||
|
|
.replace(/<[^>]+>/g, ' ')
|
||
|
|
.replace(/ /g, ' ')
|
||
|
|
.replace(/&/g, '&')
|
||
|
|
.replace(/</g, '<')
|
||
|
|
.replace(/>/g, '>')
|
||
|
|
.replace(/"/g, '"')
|
||
|
|
.replace(/'/g, "'")
|
||
|
|
.replace(/\s+/g, ' ')
|
||
|
|
.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Send parsed content to Trips API
|
||
|
|
*/
|
||
|
|
async function sendToTripsAPI(env, textContent, attachments) {
|
||
|
|
const apiUrl = env.TRIPS_API_URL || 'https://trips.quadjourney.com';
|
||
|
|
const apiKey = env.TRIPS_API_KEY;
|
||
|
|
|
||
|
|
if (!apiKey) {
|
||
|
|
throw new Error('TRIPS_API_KEY not configured');
|
||
|
|
}
|
||
|
|
|
||
|
|
// If we have PDF/image attachments, send the first one
|
||
|
|
if (attachments.length > 0) {
|
||
|
|
const attachment = attachments[0];
|
||
|
|
|
||
|
|
const formData = new FormData();
|
||
|
|
|
||
|
|
try {
|
||
|
|
const binaryData = atob(attachment.data);
|
||
|
|
const bytes = new Uint8Array(binaryData.length);
|
||
|
|
for (let i = 0; i < binaryData.length; i++) {
|
||
|
|
bytes[i] = binaryData.charCodeAt(i);
|
||
|
|
}
|
||
|
|
const blob = new Blob([bytes], { type: attachment.contentType });
|
||
|
|
formData.append('file', blob, attachment.filename);
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Failed to process attachment:', e);
|
||
|
|
}
|
||
|
|
|
||
|
|
formData.append('text', textContent);
|
||
|
|
|
||
|
|
const response = await fetch(`${apiUrl}/api/parse-email`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'X-API-Key': apiKey
|
||
|
|
},
|
||
|
|
body: formData
|
||
|
|
});
|
||
|
|
|
||
|
|
return await response.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
// Text only
|
||
|
|
const response = await fetch(`${apiUrl}/api/parse-email`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'X-API-Key': apiKey
|
||
|
|
},
|
||
|
|
body: JSON.stringify({ text: textContent })
|
||
|
|
});
|
||
|
|
|
||
|
|
return await response.json();
|
||
|
|
}
|