/** * 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(/]*>[\s\S]*?<\/style>/gi, '') .replace(/]*>[\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(); }