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:
115
services/trips/email-worker/README.md
Normal file
115
services/trips/email-worker/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Trips Email Worker
|
||||
|
||||
Forward booking confirmation emails to your Trips app for automatic parsing.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Email arrives at `travel@quadjourney.com`
|
||||
2. Cloudflare Email Routing forwards to this Worker
|
||||
3. Worker extracts email content and attachments
|
||||
4. Sends to Trips API for AI parsing
|
||||
5. You get a Telegram notification with parsed details
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### 1. Generate an API Key
|
||||
|
||||
Generate a secure random key:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### 2. Add API Key to Trips Docker
|
||||
|
||||
In your `docker-compose.yml`, add the environment variable:
|
||||
```yaml
|
||||
services:
|
||||
trips:
|
||||
environment:
|
||||
- EMAIL_API_KEY=your-generated-key-here
|
||||
- TELEGRAM_BOT_TOKEN=your-bot-token # Optional
|
||||
- TELEGRAM_CHAT_ID=your-chat-id # Optional
|
||||
```
|
||||
|
||||
Restart the container:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Create Cloudflare Worker
|
||||
|
||||
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) → Workers & Pages
|
||||
2. Click "Create Worker"
|
||||
3. Name it `trips-email-worker`
|
||||
4. Paste the contents of `worker.js`
|
||||
5. Click "Deploy"
|
||||
|
||||
### 4. Add Worker Secrets
|
||||
|
||||
In the Worker settings, add these environment variables:
|
||||
|
||||
| Variable | Value |
|
||||
|----------|-------|
|
||||
| `TRIPS_API_URL` | `https://trips.quadjourney.com` |
|
||||
| `TRIPS_API_KEY` | Your generated API key |
|
||||
| `FORWARD_TO` | (Optional) Backup email address |
|
||||
|
||||
Or via CLI:
|
||||
```bash
|
||||
cd email-worker
|
||||
npm install wrangler
|
||||
wrangler secret put TRIPS_API_KEY
|
||||
```
|
||||
|
||||
### 5. Set Up Email Routing
|
||||
|
||||
1. Go to Cloudflare Dashboard → quadjourney.com → Email → Email Routing
|
||||
2. Click "Routing Rules" → "Create address"
|
||||
3. Set:
|
||||
- Custom address: `travel`
|
||||
- Action: "Send to Worker"
|
||||
- Destination: `trips-email-worker`
|
||||
4. Click "Save"
|
||||
|
||||
### 6. Verify DNS
|
||||
|
||||
Cloudflare should auto-configure MX records. Verify:
|
||||
- MX record pointing to Cloudflare's email servers
|
||||
- SPF/DKIM records if sending replies
|
||||
|
||||
## Testing
|
||||
|
||||
Forward a booking confirmation email to `travel@quadjourney.com`.
|
||||
|
||||
Check your Telegram for the parsed result, or check server logs:
|
||||
```bash
|
||||
docker logs trips --tail 50
|
||||
```
|
||||
|
||||
## Supported Email Types
|
||||
|
||||
- Flight confirmations (airlines, booking sites)
|
||||
- Hotel/lodging reservations
|
||||
- Car rental confirmations
|
||||
- Activity bookings
|
||||
|
||||
## Attachments
|
||||
|
||||
The worker can process:
|
||||
- PDF attachments (itineraries, e-tickets)
|
||||
- Image attachments (screenshots)
|
||||
- Plain text/HTML email body
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Worker not receiving emails:**
|
||||
- Check Email Routing is enabled for domain
|
||||
- Verify MX records are configured
|
||||
- Check Worker logs in Cloudflare dashboard
|
||||
|
||||
**API returns 401:**
|
||||
- Verify API key matches in Worker and Docker
|
||||
|
||||
**No Telegram notification:**
|
||||
- Check TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set
|
||||
- Verify bot has permission to message the chat
|
||||
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();
|
||||
}
|
||||
10
services/trips/email-worker/wrangler.toml
Normal file
10
services/trips/email-worker/wrangler.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
name = "trips-email-worker"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
|
||||
[vars]
|
||||
TRIPS_API_URL = "https://trips.quadjourney.com"
|
||||
# FORWARD_TO = "yusuf@example.com" # Optional backup forwarding
|
||||
|
||||
# Add your API key as a secret:
|
||||
# wrangler secret put TRIPS_API_KEY
|
||||
Reference in New Issue
Block a user