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:
Yusuf Suleman
2026-03-28 23:20:40 -05:00
commit d3e250e361
159 changed files with 44797 additions and 0 deletions

View 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

View 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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();
}

View 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