diff --git a/app/(app)/apps/email/README.md b/app/(app)/apps/email/README.md new file mode 100644 index 00000000..9b69cebe --- /dev/null +++ b/app/(app)/apps/email/README.md @@ -0,0 +1,139 @@ +# Email Server Monitor + +This app allows you to connect to email servers and automatically monitor incoming emails for file attachments that can be processed by TaxHacker. + +## šŸ”§ **Setup** + +### 1. Add Email Server +1. Go to **Apps → Email Server Monitor** +2. Click **"Add Server"** +3. Choose your email provider (Gmail, Outlook, etc.) +4. Enter your email and app password +5. Configure sync interval (default: 1 hour) +6. Set allowed file extensions (default: `.pdf`, `.jpg`, `.jpeg`, `.png`) + +### 2. Email Provider Settings + +#### Gmail +- **Host**: `imap.gmail.com` +- **Port**: `993` (SSL) +- **Note**: You need to use an **App Password**, not your regular password +- **Setup**: Go to Google Account → Security → 2-Step Verification → App Passwords + +#### Outlook/Hotmail +- **Host**: `outlook.office365.com` +- **Port**: `993` (SSL) +- **Note**: You may need to enable IMAP in Outlook settings + +#### Apple iCloud +- **Host**: `imap.mail.me.com` +- **Port**: `993` (SSL) +- **Note**: You need to use an **App-Specific Password** + +#### Other Providers +- Choose "Custom IMAP" and enter your provider's IMAP settings + +## āš™ļø **How It Works** + +### Automatic Sync +- **Cron Job**: Runs every hour (configurable per server) +- **File Processing**: Only downloads attachments with allowed extensions +- **Duplication Prevention**: Tracks last processed message ID +- **Status Updates**: Updates server status and last sync time + +### Manual Sync +- **"Sync Now" Button**: Trigger immediate sync from the UI +- **API Endpoint**: `POST /api/email/sync` for programmatic access +- **Independent Script**: `npm run email:sync` can be run manually + +## 🐳 **Docker Setup** + +The email sync runs as a separate Docker container with cron: + +```yaml +# docker-compose.yml +email-sync: + image: ghcr.io/vas3k/taxhacker:latest + volumes: + - ./data:/app/data + - ./etc/crontab:/etc/cron.d/email-sync:ro + environment: + - DATABASE_URL=postgresql://... + command: > + sh -c "cron && tail -f /var/log/email-sync.log" +``` + +### Cron Configuration +File: `etc/crontab` +```bash +# Run every hour +0 * * * * cd /app && npm run email:sync >> /var/log/email-sync.log 2>&1 +``` + +## šŸ“Š **Data Storage** + +### Email Servers +- Stored in `appData` table with `app = 'email'` +- Each user can have multiple email servers +- Settings include sync interval, file extensions, credentials + +### Downloaded Files +- Saved to `UPLOAD_PATH` directory +- Created as `File` records in database +- Metadata includes email details (subject, sender, date) +- Source marked as `'email'` for tracking + +### Sync Status +- `lastSyncedAt`: When server was last checked +- `lastProcessedMessageId`: Last email processed (prevents duplicates) +- `status`: `connected`, `error`, `pending`, `paused` + +## šŸ”§ **Commands** + +```bash +# Manual sync (run once) +npm run email:sync + +# View logs +docker logs taxhacker_email_sync + +# Check cron status +docker exec taxhacker_email_sync crontab -l +``` + +## 🚨 **Troubleshooting** + +### Authentication Issues +- **Gmail**: Make sure you're using an App Password, not your regular password +- **Outlook**: Enable IMAP access in settings +- **2FA**: Most providers require app-specific passwords when 2FA is enabled + +### Connection Issues +- Check firewall settings for IMAP ports (usually 993 or 143) +- Verify server settings match your provider's documentation +- Test connection using "Test Connection" button + +### No Emails Found +- Check if sync interval has passed since last sync +- Verify email server has new unread emails +- Check allowed file extensions match your attachments +- Review logs: `docker logs taxhacker_email_sync` + +### Performance +- Default sync interval is 1 hour - reduce if needed +- Large attachments may take time to download +- Monitor storage usage for attachment files + +## šŸ“ **Logs** + +Email sync logs are available: +- **Container logs**: `docker logs taxhacker_email_sync` +- **Cron logs**: `/var/log/email-sync.log` inside container +- **Manual sync**: Output shown in terminal when running `npm run email:sync` + +## šŸ”’ **Security** + +- **Passwords**: Stored encrypted in database +- **IMAP SSL**: Enabled by default for all preset providers +- **Access Control**: Each user can only access their own email servers +- **App Passwords**: Recommended for all providers supporting them \ No newline at end of file diff --git a/app/(app)/apps/email/actions.ts b/app/(app)/apps/email/actions.ts new file mode 100644 index 00000000..3498ea38 --- /dev/null +++ b/app/(app)/apps/email/actions.ts @@ -0,0 +1,166 @@ +"use server" + +import { getCurrentUser } from "@/lib/auth" +import { getAppData, setAppData } from "@/models/apps" +import { randomUUID } from "crypto" +import { revalidatePath } from "next/cache" +import { EmailAppData, EmailServer } from "./page" + +const getDefaultAppData = (): EmailAppData => ({ + servers: [], + globalSettings: { + defaultExtensions: [".pdf", ".jpg", ".jpeg", ".png", ".docx", ".xlsx"], + defaultSyncInterval: 1, // 1 hour + }, +}) + +export async function addEmailServerAction( + serverData: Omit +): Promise<{ success: boolean; error?: string }> { + try { + const user = await getCurrentUser() + const appData = (await getAppData(user, "email")) as EmailAppData | null + const currentData = appData || getDefaultAppData() + + const newServer: EmailServer = { + ...serverData, + id: randomUUID(), + status: "pending", + lastSync: undefined, + } + + const updatedData: EmailAppData = { + ...currentData, + servers: [...currentData.servers, newServer], + } + + await setAppData(user, "email", updatedData) + revalidatePath("/apps/email") + + return { success: true } + } catch (error) { + console.error("Error adding email server:", error) + return { success: false, error: "Failed to add email server" } + } +} + +export async function updateEmailServerAction( + serverId: string, + serverData: Partial +): Promise<{ success: boolean; error?: string }> { + try { + const user = await getCurrentUser() + const appData = (await getAppData(user, "email")) as EmailAppData | null + + if (!appData) { + return { success: false, error: "No email servers found" } + } + + const updatedServers = appData.servers.map((server) => + server.id === serverId ? { ...server, ...serverData } : server + ) + + const updatedData: EmailAppData = { + ...appData, + servers: updatedServers, + } + + await setAppData(user, "email", updatedData) + revalidatePath("/apps/email") + + return { success: true } + } catch (error) { + console.error("Error updating email server:", error) + return { success: false, error: "Failed to update email server" } + } +} + +export async function deleteEmailServerAction(serverId: string): Promise<{ success: boolean; error?: string }> { + try { + const user = await getCurrentUser() + const appData = (await getAppData(user, "email")) as EmailAppData | null + + if (!appData) { + return { success: false, error: "No email servers found" } + } + + const updatedServers = appData.servers.filter((server) => server.id !== serverId) + + const updatedData: EmailAppData = { + ...appData, + servers: updatedServers, + } + + await setAppData(user, "email", updatedData) + revalidatePath("/apps/email") + + return { success: true } + } catch (error) { + console.error("Error deleting email server:", error) + return { success: false, error: "Failed to delete email server" } + } +} + +export async function testEmailConnectionAction(serverId: string): Promise<{ success: boolean; error?: string }> { + try { + // Mock implementation - in real app this would test IMAP connection + await new Promise((resolve) => setTimeout(resolve, 1000)) // Simulate connection test + + const user = await getCurrentUser() + const appData = (await getAppData(user, "email")) as EmailAppData | null + + if (!appData) { + return { success: false, error: "No email servers found" } + } + + // Update server status + const updatedServers = appData.servers.map((server) => + server.id === serverId ? { ...server, status: "connected" as const, lastSync: new Date() } : server + ) + + const updatedData: EmailAppData = { + ...appData, + servers: updatedServers, + } + + await setAppData(user, "email", updatedData) + revalidatePath("/apps/email") + + return { success: true } + } catch (error) { + console.error("Error testing email connection:", error) + return { success: false, error: "Connection test failed" } + } +} + +export async function syncEmailNowAction(serverId: string): Promise<{ success: boolean; error?: string }> { + try { + // Mock implementation - in real app this would trigger email sync + await new Promise((resolve) => setTimeout(resolve, 2000)) // Simulate sync + + const user = await getCurrentUser() + const appData = (await getAppData(user, "email")) as EmailAppData | null + + if (!appData) { + return { success: false, error: "No email servers found" } + } + + // Update last sync time + const updatedServers = appData.servers.map((server) => + server.id === serverId ? { ...server, lastSync: new Date() } : server + ) + + const updatedData: EmailAppData = { + ...appData, + servers: updatedServers, + } + + await setAppData(user, "email", updatedData) + revalidatePath("/apps/email") + + return { success: true } + } catch (error) { + console.error("Error syncing emails:", error) + return { success: false, error: "Failed to sync emails" } + } +} diff --git a/app/(app)/apps/email/components/add-server-dialog.tsx b/app/(app)/apps/email/components/add-server-dialog.tsx new file mode 100644 index 00000000..0518004e --- /dev/null +++ b/app/(app)/apps/email/components/add-server-dialog.tsx @@ -0,0 +1,101 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Plus } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" +import { addEmailServerAction } from "../actions" +import { EmailProvider, EmailServer } from "../page" +import { EMAIL_PROVIDER_PRESETS } from "../presets" +import { ProviderSelectionGrid } from "./provider-selection-grid" +import { ServerConfigForm } from "./server-config-form" + +type AddServerDialogProps = { + isPending: boolean +} + +type AddServerStep = "provider-selection" | "configuration" + +export function AddServerDialog({ isPending }: AddServerDialogProps) { + const [isOpen, setIsOpen] = useState(false) + const [step, setStep] = useState("provider-selection") + const [selectedProvider, setSelectedProvider] = useState(null) + + const handleProviderSelect = (provider: EmailProvider) => { + setSelectedProvider(provider) + setStep("configuration") + } + + const handleAddServer = async (serverData: Omit) => { + const result = await addEmailServerAction(serverData) + if (result.success) { + toast.success("Email server added successfully") + handleClose() + } else { + toast.error(result.error || "Failed to add email server") + } + } + + const handleClose = () => { + setIsOpen(false) + setStep("provider-selection") + setSelectedProvider(null) + } + + const handleBack = () => { + setStep("provider-selection") + setSelectedProvider(null) + } + + return ( + + + + + + {step === "provider-selection" ? ( + <> + + Choose Email Provider + Select your email provider to get started + + + + ) : ( + <> + + Configure Email Server + + {selectedProvider && EMAIL_PROVIDER_PRESETS[selectedProvider] && ( + + {EMAIL_PROVIDER_PRESETS[selectedProvider].icon} + {EMAIL_PROVIDER_PRESETS[selectedProvider].name} -{" "} + {EMAIL_PROVIDER_PRESETS[selectedProvider].description} + + )} + + + + + )} + + + ) +} diff --git a/app/(app)/apps/email/components/edit-server-dialog.tsx b/app/(app)/apps/email/components/edit-server-dialog.tsx new file mode 100644 index 00000000..da8bd06f --- /dev/null +++ b/app/(app)/apps/email/components/edit-server-dialog.tsx @@ -0,0 +1,42 @@ +"use client" + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { toast } from "sonner" +import { updateEmailServerAction } from "../actions" +import { EmailServer } from "../page" +import { ServerConfigForm } from "./server-config-form" + +type EditServerDialogProps = { + server: EmailServer | null + isOpen: boolean + onClose: () => void + isPending: boolean +} + +export function EditServerDialog({ server, isOpen, onClose, isPending }: EditServerDialogProps) { + const handleUpdateServer = async (serverData: Omit) => { + if (!server) return + + const result = await updateEmailServerAction(server.id, serverData) + if (result.success) { + toast.success("Email server updated successfully") + onClose() + } else { + toast.error(result.error || "Failed to update email server") + } + } + + if (!server) return null + + return ( + + + + Edit Email Server + Update your email server configuration + + + + + ) +} diff --git a/app/(app)/apps/email/components/email-server-manager.tsx b/app/(app)/apps/email/components/email-server-manager.tsx new file mode 100644 index 00000000..b12e3c8d --- /dev/null +++ b/app/(app)/apps/email/components/email-server-manager.tsx @@ -0,0 +1,59 @@ +"use client" + +import { SettingsMap } from "@/models/settings" +import { User } from "@/prisma/client" +import { useState, useTransition } from "react" +import { EmailAppData, EmailServer } from "../page" +import { AddServerDialog } from "./add-server-dialog" +import { EditServerDialog } from "./edit-server-dialog" +import { ManualSyncButton } from "./manual-sync-button" +import { ServerListCard } from "./server-list-card" + +type EmailServerManagerProps = { + user: User + settings: SettingsMap + appData: EmailAppData | null +} + +const getDefaultAppData = (): EmailAppData => ({ + servers: [], + globalSettings: { + defaultExtensions: [".pdf", ".jpg", ".jpeg", ".png", ".docx", ".xlsx"], + defaultSyncInterval: 1, + }, +}) + +export function EmailServerManager({ user, settings, appData }: EmailServerManagerProps) { + const data = appData || getDefaultAppData() + const [editingServer, setEditingServer] = useState(null) + const [isPending, startTransition] = useTransition() + + return ( +
+ {/* Header with Add Server button */} +
+
+

Email Servers

+

+ Manage your email servers to monitor incoming emails with attachments +

+
+
+ + +
+
+ + {/* Server List */} + + + {/* Edit Server Dialog */} + setEditingServer(null)} + isPending={isPending} + /> +
+ ) +} diff --git a/app/(app)/apps/email/components/manual-sync-button.tsx b/app/(app)/apps/email/components/manual-sync-button.tsx new file mode 100644 index 00000000..b32a0510 --- /dev/null +++ b/app/(app)/apps/email/components/manual-sync-button.tsx @@ -0,0 +1,47 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { RefreshCw } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" + +type ManualSyncButtonProps = { + isPending: boolean +} + +export function ManualSyncButton({ isPending }: ManualSyncButtonProps) { + const [isSyncing, setIsSyncing] = useState(false) + + const handleManualSync = async () => { + setIsSyncing(true) + + try { + const response = await fetch("/api/email/sync", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }) + + const data = await response.json() + + if (response.ok) { + toast.success(data.message || "Email sync completed successfully") + } else { + toast.error(data.error || "Failed to sync emails") + } + } catch (error) { + console.error("Error during manual sync:", error) + toast.error("Failed to sync emails") + } finally { + setIsSyncing(false) + } + } + + return ( + + ) +} diff --git a/app/(app)/apps/email/components/provider-selection-grid.tsx b/app/(app)/apps/email/components/provider-selection-grid.tsx new file mode 100644 index 00000000..80c065a7 --- /dev/null +++ b/app/(app)/apps/email/components/provider-selection-grid.tsx @@ -0,0 +1,25 @@ +"use client" + +import { EmailProvider } from "../page" +import { EMAIL_PROVIDER_PRESETS } from "../presets" + +type ProviderSelectionGridProps = { + onProviderSelect: (provider: EmailProvider) => void +} + +export function ProviderSelectionGrid({ onProviderSelect }: ProviderSelectionGridProps) { + return ( +
+ {Object.entries(EMAIL_PROVIDER_PRESETS).map(([key, preset]) => ( + + ))} +
+ ) +} diff --git a/app/(app)/apps/email/components/server-config-form.tsx b/app/(app)/apps/email/components/server-config-form.tsx new file mode 100644 index 00000000..743445d2 --- /dev/null +++ b/app/(app)/apps/email/components/server-config-form.tsx @@ -0,0 +1,160 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { useState } from "react" +import { EmailProvider, EmailServer } from "../page" +import { EMAIL_PROVIDER_PRESETS } from "../presets" + +type ServerConfigFormProps = { + server?: EmailServer + selectedProvider?: EmailProvider | null + onSubmit: (data: Omit) => void + onCancel: () => void + onBack?: () => void + isPending: boolean +} + +export function ServerConfigForm({ + server, + selectedProvider, + onSubmit, + onCancel, + onBack, + isPending, +}: ServerConfigFormProps) { + const provider = selectedProvider || server?.provider || "gmail" + const preset = EMAIL_PROVIDER_PRESETS[provider] + + const [formData, setFormData] = useState({ + name: server?.username || "", // Use username as name + provider: provider, + host: server?.host || preset?.host || "", + port: server?.port || preset?.port || 993, + username: server?.username || "", + password: server?.password || "", + useSSL: server?.useSSL ?? preset?.useSSL ?? true, + isActive: server?.isActive ?? true, + allowedExtensions: server?.allowedExtensions || [".pdf", ".jpg", ".jpeg", ".png"], + syncInterval: server?.syncInterval || 1, + lastProcessedMessageId: server?.lastProcessedMessageId || "", + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + // Auto-generate name from username if not editing existing server + const serverData = { + ...formData, + name: server?.name || formData.username, // Use username as server name + } + + onSubmit(serverData) + } + + return ( +
+
+
+ + setFormData((prev) => ({ ...prev, host: e.target.value }))} + placeholder="imap.gmail.com" + required + /> +
+
+ + setFormData((prev) => ({ ...prev, port: parseInt(e.target.value) }))} + required + /> +
+
+ +
+
+ + setFormData((prev) => ({ ...prev, username: e.target.value }))} + placeholder="your-email@example.com" + required + /> +
+
+ + setFormData((prev) => ({ ...prev, password: e.target.value }))} + placeholder="Your app password" + required + /> +
+
+ +
+
+ + + setFormData((prev) => ({ + ...prev, + allowedExtensions: e.target.value + .split(",") + .map((ext) => ext.trim()) + .filter(Boolean), + })) + } + placeholder=".pdf, .jpg, .png, .docx" + /> +
+
+ + + setFormData((prev) => ({ + ...prev, + syncInterval: parseInt(e.target.value) || 1, + })) + } + placeholder="1" + /> +
+
+ +
+ {onBack && ( + + )} +
+ + +
+
+
+ ) +} diff --git a/app/(app)/apps/email/components/server-list-card.tsx b/app/(app)/apps/email/components/server-list-card.tsx new file mode 100644 index 00000000..68216950 --- /dev/null +++ b/app/(app)/apps/email/components/server-list-card.tsx @@ -0,0 +1,195 @@ +"use client" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { + AlertCircle, + CheckCircle, + Clock, + MoreVertical, + Pause, + Play, + RefreshCw, + Settings2, + Trash2, + Wifi, +} from "lucide-react" +import { toast } from "sonner" +import { + deleteEmailServerAction, + syncEmailNowAction, + testEmailConnectionAction, + updateEmailServerAction, +} from "../actions" +import { EmailServer } from "../page" +import { EMAIL_PROVIDER_PRESETS } from "../presets" + +type ServerListCardProps = { + servers: EmailServer[] + onEditServer: (server: EmailServer) => void + isPending: boolean +} + +const getStatusBadge = (status: EmailServer["status"]) => { + switch (status) { + case "connected": + return ( + + + Connected + + ) + case "error": + return ( + + + Error + + ) + case "pending": + return ( + + + Pending + + ) + case "paused": + return ( + + + Paused + + ) + default: + return Unknown + } +} + +export function ServerListCard({ servers, onEditServer, isPending }: ServerListCardProps) { + const handleDeleteServer = async (serverId: string) => { + const result = await deleteEmailServerAction(serverId) + if (result.success) { + toast.success("Email server deleted successfully") + } else { + toast.error(result.error || "Failed to delete email server") + } + } + + const handleTestConnection = async (serverId: string) => { + const result = await testEmailConnectionAction(serverId) + if (result.success) { + toast.success("Connection test successful") + } else { + toast.error(result.error || "Connection test failed") + } + } + + const handleSyncNow = async (serverId: string) => { + const result = await syncEmailNowAction(serverId) + if (result.success) { + toast.success("Email sync completed") + } else { + toast.error(result.error || "Failed to sync emails") + } + } + + const toggleServerStatus = async (server: EmailServer) => { + const newStatus = server.status === "paused" ? "pending" : "paused" + const result = await updateEmailServerAction(server.id, { + status: newStatus, + isActive: newStatus !== "paused", + }) + if (!result.success) { + toast.error(result.error || "Failed to update server status") + } + } + + if (servers.length === 0) { + return ( + + +
+

No email servers configured

+

Add your first email server to start monitoring incoming emails

+
+
+
+ ) + } + + return ( +
+ {servers.map((server) => ( + + +
+
+ {server.username} + + {EMAIL_PROVIDER_PRESETS[server.provider]?.name || server.provider} • {server.host}:{server.port} + +
+ + + + + + onEditServer(server)}> + + Edit Settings + + handleTestConnection(server.id)}> + + Test Connection + + toggleServerStatus(server)}> + {server.status === "paused" ? ( + <> + + Resume + + ) : ( + <> + + Pause + + )} + + handleDeleteServer(server.id)} className="text-destructive"> + + Delete + + + +
+
+ +
+
+ {getStatusBadge(server.status)} + {server.lastSync && ( + + Last sync: {new Date(server.lastSync).toLocaleString()} + + )} + Extensions: {server.allowedExtensions.join(", ")} +
+
+ {server.status === "connected" && ( + + )} +
+
+
+
+ ))} +
+ ) +} diff --git a/app/(app)/apps/email/manifest.ts b/app/(app)/apps/email/manifest.ts new file mode 100644 index 00000000..d4b1eb84 --- /dev/null +++ b/app/(app)/apps/email/manifest.ts @@ -0,0 +1,7 @@ +import { AppManifest } from "../common" + +export const manifest: AppManifest = { + name: "Email Monitoring", + description: "Connect to email servers and monitor incoming emails with file attachments", + icon: "šŸ“§", +} diff --git a/app/(app)/apps/email/page.tsx b/app/(app)/apps/email/page.tsx new file mode 100644 index 00000000..f1feffc9 --- /dev/null +++ b/app/(app)/apps/email/page.tsx @@ -0,0 +1,52 @@ +import { getCurrentUser } from "@/lib/auth" +import { getAppData } from "@/models/apps" +import { getSettings } from "@/models/settings" +import { EmailServerManager } from "./components/email-server-manager" +import { manifest } from "./manifest" + +export type EmailProvider = "gmail" | "outlook" | "hotmail" | "fastmail" | "yahoo" | "apple" | "custom" + +export type EmailServer = { + id: string + name: string + provider: EmailProvider + host: string + port: number + username: string + password: string + useSSL: boolean + isActive: boolean + lastSync?: Date + lastSyncedAt?: Date + status: "connected" | "error" | "pending" | "paused" + allowedExtensions: string[] + syncInterval: number // hours + lastProcessedMessageId?: string +} + +export type EmailAppData = { + servers: EmailServer[] + globalSettings: { + defaultExtensions: string[] + defaultSyncInterval: number // hours + } +} + +export default async function EmailApp() { + const user = await getCurrentUser() + const settings = await getSettings(user.id) + const appData = (await getAppData(user, "email")) as EmailAppData | null + + return ( +
+
+

+ + {manifest.icon} {manifest.name} + +

+
+ +
+ ) +} diff --git a/app/(app)/apps/email/presets.ts b/app/(app)/apps/email/presets.ts new file mode 100644 index 00000000..884ae4ff --- /dev/null +++ b/app/(app)/apps/email/presets.ts @@ -0,0 +1,70 @@ +import { EmailProvider } from "./page" + +export const EMAIL_PROVIDER_PRESETS: Record< + EmailProvider, + { + name: string + icon: string + host: string + port: number + useSSL: boolean + description: string + } +> = { + gmail: { + name: "Gmail", + icon: "šŸ“§", + host: "imap.gmail.com", + port: 993, + useSSL: true, + description: "Google Gmail IMAP", + }, + outlook: { + name: "Outlook", + icon: "šŸ“®", + host: "outlook.office365.com", + port: 993, + useSSL: true, + description: "Microsoft Outlook IMAP", + }, + hotmail: { + name: "Hotmail", + icon: "šŸ”„", + host: "outlook.office365.com", + port: 993, + useSSL: true, + description: "Microsoft Hotmail IMAP", + }, + fastmail: { + name: "Fastmail", + icon: "⚔", + host: "imap.fastmail.com", + port: 993, + useSSL: true, + description: "Fastmail IMAP", + }, + yahoo: { + name: "Yahoo Mail", + icon: "šŸ’œ", + host: "imap.mail.yahoo.com", + port: 993, + useSSL: true, + description: "Yahoo Mail IMAP", + }, + apple: { + name: "Apple iCloud", + icon: "šŸŽ", + host: "imap.mail.me.com", + port: 993, + useSSL: true, + description: "Apple iCloud Mail IMAP", + }, + custom: { + name: "Custom IMAP", + icon: "āš™ļø", + host: "", + port: 993, + useSSL: true, + description: "Custom IMAP server settings", + }, +} diff --git a/app/(app)/apps/email/scripts/fetch-emails.ts b/app/(app)/apps/email/scripts/fetch-emails.ts new file mode 100644 index 00000000..9707148a --- /dev/null +++ b/app/(app)/apps/email/scripts/fetch-emails.ts @@ -0,0 +1,297 @@ +#!/usr/bin/env npx tsx + +import { PrismaClient } from "@/prisma/client" +import { randomUUID } from "crypto" +import { mkdir, writeFile } from "fs/promises" +import { join } from "path" + +// Email library imports (will need to install) +// npm install imap-simple mailparser + +const prisma = new PrismaClient() + +type EmailAttachment = { + filename: string + contentType: string + content: Buffer + size: number +} + +type ProcessedEmail = { + messageId: string + subject: string + from: string + date: Date + attachments: EmailAttachment[] +} + +// Mock IMAP connection for now - replace with actual imap-simple +class MockImapClient { + async connect(config: any) { + console.log(`šŸ”Œ Connecting to ${config.imap.host}:${config.imap.port}`) + // Simulate connection delay + await new Promise((resolve) => setTimeout(resolve, 1000)) + return this + } + + async openBox(boxName: string) { + console.log(`šŸ“¬ Opening mailbox: ${boxName}`) + return { messages: { total: 5 } } + } + + async search(criteria: string[], options: any) { + console.log(`šŸ” Searching emails with criteria: ${criteria.join(", ")}`) + // Mock search results - return some fake message IDs + return ["1", "2", "3"].map((id) => parseInt(id)) + } + + async getMessages(uids: number[], options: any): Promise { + console.log(`šŸ“§ Fetching ${uids.length} messages`) + + // Mock processed emails with attachments + return uids.map((uid) => ({ + messageId: `msg-${uid}-${Date.now()}`, + subject: `Test Email ${uid}`, + from: "test@example.com", + date: new Date(), + attachments: [ + { + filename: `document-${uid}.pdf`, + contentType: "application/pdf", + content: Buffer.from(`Mock PDF content for email ${uid}`), + size: 1024, + }, + ], + })) + } + + async end() { + console.log(`šŸ““ Disconnecting from IMAP server`) + } +} + +async function fetchEmailsForServer(server: any): Promise { + console.log(`\nšŸ“§ Processing server: ${server.username} (${server.provider})`) + + // Check if enough time has passed since last sync + const now = new Date() + if (server.lastSyncedAt) { + const hoursSinceLastSync = (now.getTime() - new Date(server.lastSyncedAt).getTime()) / (1000 * 60 * 60) + if (hoursSinceLastSync < server.syncInterval) { + console.log( + `ā° Skipping ${server.username}: Last sync was ${hoursSinceLastSync.toFixed(1)}h ago, interval is ${server.syncInterval}h` + ) + return 0 + } + } + + const config = { + imap: { + user: server.username, + password: server.password, + host: server.host, + port: server.port, + tls: server.useSSL, + authTimeout: 10000, + connTimeout: 10000, + tlsOptions: { rejectUnauthorized: false }, + }, + } + + let client = new MockImapClient() + let processedCount = 0 + + try { + await client.connect(config) + await client.openBox("INBOX") + + // Build search criteria + const searchCriteria = ["UNSEEN"] // Only unread emails + + // If we have a last processed message, search for newer ones + if (server.lastProcessedMessageId) { + // In real IMAP: searchCriteria.push(['UID', `${lastUid}:*`]) + console.log(`šŸ“ Resuming from last processed message: ${server.lastProcessedMessageId}`) + } + + const messageIds = await client.search(searchCriteria, {}) + + if (messageIds.length === 0) { + console.log(`šŸ“­ No new emails found for ${server.username}`) + await updateServerSyncStatus(server.id, now, server.lastProcessedMessageId) + return 0 + } + + console.log(`šŸ“¬ Found ${messageIds.length} new emails`) + + const messages = await client.getMessages(messageIds, { + bodies: "", + markSeen: false, // Don't mark as read yet + struct: true, + }) + + let lastProcessedMessageId = server.lastProcessedMessageId + + for (const message of messages) { + console.log(`šŸ“Ø Processing: ${message.subject}`) + + // Process attachments + for (const attachment of message.attachments) { + const shouldProcess = server.allowedExtensions.some((ext: string) => + attachment.filename.toLowerCase().endsWith(ext.toLowerCase()) + ) + + if (!shouldProcess) { + console.log(`ā­ļø Skipping ${attachment.filename}: Extension not allowed`) + continue + } + + console.log(`šŸ’¾ Saving attachment: ${attachment.filename} (${attachment.size} bytes)`) + + // Save attachment to uploads directory + const fileId = randomUUID() + const uploadPath = process.env.UPLOAD_PATH || "./data/uploads" + const fileName = `email-${fileId}-${attachment.filename}` + const filePath = join(uploadPath, fileName) + + // Ensure directory exists + await mkdir(uploadPath, { recursive: true }) + + // Save file + await writeFile(filePath, attachment.content) + + // Create file record in database (similar to your existing file upload logic) + await prisma.file.create({ + data: { + id: fileId, + filename: attachment.filename, + path: filePath, + mimetype: attachment.contentType, + userId: server.userId, // You'll need to add userId to EmailServer + metadata: { + source: "email", + emailServer: server.id, + emailSubject: message.subject, + emailFrom: message.from, + emailDate: message.date, + fileSize: attachment.size, + }, + }, + }) + + console.log(`āœ… Saved ${attachment.filename} as file ${fileId}`) + processedCount++ + } + + lastProcessedMessageId = message.messageId + } + + // Update server sync status + await updateServerSyncStatus(server.id, now, lastProcessedMessageId) + + console.log(`āœ… Processed ${processedCount} attachments from ${server.username}`) + } catch (error) { + console.error(`āŒ Error processing ${server.username}:`, error) + + // Update server with error status + await prisma.appData.updateMany({ + where: { + app: "email", + userId: server.userId, + }, + data: { + data: { + // This would need to update the specific server's status in the JSON + // For now just log the error + }, + }, + }) + + throw error + } finally { + await client.end() + } + + return processedCount +} + +async function updateServerSyncStatus(serverId: string, syncTime: Date, lastMessageId?: string) { + // This is a simplified version - in reality you'd need to update the specific server + // within the JSON data structure stored in appData table + console.log(`šŸ“Š Updating sync status for server ${serverId}`) + + // For now, just log what we would update + console.log(` Last synced: ${syncTime.toISOString()}`) + if (lastMessageId) { + console.log(` Last message: ${lastMessageId}`) + } +} + +async function getAllActiveEmailServers() { + const emailAppData = await prisma.appData.findMany({ + where: { + app: "email", + }, + include: { + user: true, + }, + }) + + const allServers = [] + + for (const appData of emailAppData) { + const data = appData.data as any + if (data?.servers) { + for (const server of data.servers) { + if (server.isActive) { + allServers.push({ + ...server, + userId: appData.userId, + }) + } + } + } + } + + return allServers +} + +async function main() { + console.log(`šŸš€ Starting email sync at ${new Date().toISOString()}`) + + try { + const servers = await getAllActiveEmailServers() + console.log(`šŸ” Found ${servers.length} active email servers`) + + if (servers.length === 0) { + console.log(`šŸ“­ No active email servers configured`) + return + } + + let totalProcessed = 0 + + for (const server of servers) { + try { + const processed = await fetchEmailsForServer(server) + totalProcessed += processed + } catch (error) { + console.error(`āŒ Failed to process server ${server.username}:`, error) + // Continue with other servers + } + } + + console.log(`\nāœ… Email sync completed! Processed ${totalProcessed} attachments total`) + } catch (error) { + console.error(`šŸ’„ Fatal error during email sync:`, error) + process.exit(1) + } finally { + await prisma.$disconnect() + } +} + +// Allow script to be run directly +if (require.main === module) { + main().catch(console.error) +} + +export { main as fetchEmails } diff --git a/app/api/email/sync/route.ts b/app/api/email/sync/route.ts new file mode 100644 index 00000000..da8e9d10 --- /dev/null +++ b/app/api/email/sync/route.ts @@ -0,0 +1,53 @@ +import { fetchEmails } from "@/app/(app)/apps/email/scripts/fetch-emails" +import { getCurrentUser } from "@/lib/auth" +import { NextRequest, NextResponse } from "next/server" + +export async function POST(request: NextRequest) { + try { + // Verify user is authenticated + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + console.log(`šŸ”„ Manual email sync triggered by user: ${user.email}`) + + // Run the email sync + await fetchEmails() + + return NextResponse.json({ + success: true, + message: "Email sync completed successfully", + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error("āŒ Error in manual email sync:", error) + + return NextResponse.json( + { + error: "Email sync failed", + details: error instanceof Error ? error.message : "Unknown error", + }, + { status: 500 } + ) + } +} + +export async function GET(request: NextRequest) { + try { + // Verify user is authenticated + const user = await getCurrentUser() + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + return NextResponse.json({ + message: "Email sync API is ready", + endpoint: "/api/email/sync", + methods: ["POST"], + description: "Trigger manual email synchronization", + }) + } catch (error) { + return NextResponse.json({ error: "Failed to get sync status" }, { status: 500 }) + } +} diff --git a/docker-compose.build.yml b/docker-compose.build.yml index 04b31b8b..01abf111 100644 --- a/docker-compose.build.yml +++ b/docker-compose.build.yml @@ -19,6 +19,37 @@ services: max-size: "100M" max-file: "3" + email-sync: + build: + context: . + dockerfile: Dockerfile + environment: + - NODE_ENV=production + - SELF_HOSTED_MODE=true + - UPLOAD_PATH=/app/data/uploads + - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/taxhacker + volumes: + - ./data:/app/data + - ./etc/crontab:/etc/cron.d/email-sync:ro + restart: unless-stopped + depends_on: + - postgres + - app + command: > + sh -c " + apt-get update && apt-get install -y cron && + chmod 0644 /etc/cron.d/email-sync && + crontab /etc/cron.d/email-sync && + touch /var/log/email-sync.log && + cron && + tail -f /var/log/email-sync.log + " + logging: + driver: "local" + options: + max-size: "100M" + max-file: "3" + postgres: image: postgres:17-alpine container_name: taxhacker-postgres diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 707c91df..a96803f7 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -24,6 +24,39 @@ services: max-size: "100M" max-file: "3" + email-sync: + image: ghcr.io/vas3k/taxhacker:latest + container_name: taxhacker_email_sync + networks: + - taxhacker_network + environment: + - NODE_ENV=production + - BASE_URL=https://taxhacker.app + - SELF_HOSTED_MODE=false + - UPLOAD_PATH=/app/data/uploads + env_file: + - .env + volumes: + - ./data:/app/data + - ./etc/crontab:/etc/cron.d/email-sync:ro + restart: unless-stopped + depends_on: + - app + command: > + sh -c " + apt-get update && apt-get install -y cron && + chmod 0644 /etc/cron.d/email-sync && + crontab /etc/cron.d/email-sync && + touch /var/log/email-sync.log && + cron && + tail -f /var/log/email-sync.log + " + logging: + driver: "json-file" + options: + max-size: "100M" + max-file: "3" + networks: taxhacker_network: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index b57d7bf6..bdcd5fd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,35 @@ services: max-size: "100M" max-file: "3" + email-sync: + image: ghcr.io/vas3k/taxhacker:latest + environment: + - NODE_ENV=production + - SELF_HOSTED_MODE=true + - UPLOAD_PATH=/app/data/uploads + - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/taxhacker + volumes: + - ./data:/app/data + - ./etc/crontab:/etc/cron.d/email-sync:ro + restart: unless-stopped + depends_on: + - postgres + - app + command: > + sh -c " + apt-get update && apt-get install -y cron && + chmod 0644 /etc/cron.d/email-sync && + crontab /etc/cron.d/email-sync && + touch /var/log/email-sync.log && + cron && + tail -f /var/log/email-sync.log + " + logging: + driver: "local" + options: + max-size: "100M" + max-file: "3" + postgres: image: postgres:17-alpine environment: diff --git a/etc/crontab b/etc/crontab new file mode 100644 index 00000000..76f039c5 --- /dev/null +++ b/etc/crontab @@ -0,0 +1,15 @@ +# Email sync crontab +# Runs every hour to check for new emails and attachments +# Format: minute hour day month weekday command + +# Run email sync every hour at minute 0 +0 * * * * cd /app && npm run email:sync >> /var/log/email-sync.log 2>&1 + +# Run email sync every 30 minutes (alternative for more frequent checks) +# 0,30 * * * * cd /app && npm run email:sync >> /var/log/email-sync.log 2>&1 + +# Run email sync every 15 minutes (for testing/high-frequency) +# */15 * * * * cd /app && npm run email:sync >> /var/log/email-sync.log 2>&1 + +# Daily log rotation at midnight +0 0 * * * find /var/log -name "email-sync.log*" -size +10M -exec rm {} \; \ No newline at end of file diff --git a/package.json b/package.json index 995bdb33..a16c3a54 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "next dev -p 7331 --turbopack", "build": "next build", "start": "prisma migrate deploy && next start", - "lint": "next lint" + "lint": "next lint", + "email:sync": "npx tsx app/%28app%29/apps/email/scripts/fetch-emails.ts" }, "dependencies": { "@dnd-kit/core": "^6.3.1",