Initial commit: Second Brain Platform
Complete platform with unified design system and real API integration. Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings Infrastructure: SvelteKit + Python gateway + Docker Compose
This commit is contained in:
325
services/trips/email-worker/worker.js
Normal file
325
services/trips/email-worker/worker.js
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user