From 18c11d27bc1beff1d4a0fa5e2f443c3f3825490f Mon Sep 17 00:00:00 2001 From: ANDJ Date: Mon, 26 Jan 2026 17:49:39 +0100 Subject: [PATCH] feat: AI Control Settings mit Category Control und Company Labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MAJOR FEATURES: - AI Control Tab in Settings hinzugefügt mit vollständiger KI-Steuerung - Category Control: Benutzer können Kategorien aktivieren/deaktivieren und Aktionen pro Kategorie festlegen (Keep in Inbox, Archive & Mark Read, Star) - Company Labels: Automatische Erkennung bekannter Firmen (Amazon, Google, Microsoft, etc.) und optionale benutzerdefinierte Company Labels - Auto-Detect Companies Toggle: Automatische Label-Erstellung für bekannte Firmen UI/UX VERBESSERUNGEN: - Sorting Rules Tab entfernt (war zu verwirrend) - Save Buttons nach oben rechts verschoben (Category Control und Company Labels) - Company Labels Section: Custom Labels sind jetzt in einem ausklappbaren Details-Element (Optional) - Verbesserte Beschreibungen und Klarheit in der UI BACKEND ÄNDERUNGEN: - Neue API Endpoints: /api/preferences/ai-control (GET/POST) und /api/preferences/company-labels (GET/POST/DELETE) - AI Sorter Service erweitert: detectCompany(), matchesCompanyLabel(), getCategoryAction(), getEnabledCategories() - Database Service: Default-Werte und Merge-Logik für erweiterte User Preferences - Email Routes: Integration der neuen AI Control Einstellungen in Gmail und Outlook Sortierung - Label-Erstellung: Nur für enabledCategories, Custom Company Labels mit orange Farbe (#ff9800) FRONTEND ÄNDERUNGEN: - Neue TypeScript Types: client/src/types/settings.ts (AIControlSettings, CompanyLabel, CategoryInfo, KnownCompany) - Settings.tsx: Komplett überarbeitet mit AI Control Tab, Category Toggles, Company Labels Management - API Client erweitert: getAIControlSettings(), saveAIControlSettings(), getCompanyLabels(), saveCompanyLabel(), deleteCompanyLabel() - Debug-Logs hinzugefügt für Troubleshooting (main.tsx, App.tsx, Settings.tsx) BUGFIXES: - JSX Syntax-Fehler behoben: Fehlende schließende Tags in Company Labels Section - TypeScript Typ-Fehler behoben: saved.data null-check für Company Labels - Struktur-Fehler behoben: Conditional Blocks korrekt verschachtelt TECHNISCHE DETAILS: - 9 Kategorien verfügbar: VIP, Clients, Invoices, Newsletter, Promotions, Social, Security, Calendar, Review - Company Labels unterstützen Bedingungen wie 'from:amazon.com OR from:amazon.de' - Priorisierung: 1) Custom Company Labels, 2) Auto-Detected Companies, 3) AI Categorization - Deaktivierte Kategorien werden automatisch als 'review' kategorisiert --- client/src/App.tsx | 12 + client/src/lib/api.ts | 62 +++++ client/src/main.tsx | 6 + client/src/pages/Settings.tsx | 455 ++++++++++++++++++++++++++++++---- client/src/types/settings.ts | 32 +++ server/index.mjs | 99 ++++++++ server/routes/email.mjs | 194 ++++++++++++--- server/services/ai-sorter.mjs | 146 ++++++++++- server/services/database.mjs | 43 +++- 9 files changed, 963 insertions(+), 86 deletions(-) create mode 100644 client/src/types/settings.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 8ccdb94..b84db5f 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,12 @@ import { Imprint } from '@/pages/Imprint' // Initialize analytics on app startup initAnalytics() +// #region agent log +try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.tsx:18',message:'App.tsx loaded, importing Settings',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{}); +} catch(e) {} +// #endregion + // Loading spinner component function LoadingSpinner() { return ( @@ -63,6 +69,12 @@ function AppRoutes() { // Track page views on route changes usePageTracking() + // #region agent log + try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'App.tsx:64',message:'AppRoutes rendering',data:{pathname:window.location.pathname},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'D'})}).catch(()=>{}); + } catch(e) {} + // #endregion + return ( {/* Public pages */} diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 8acb3d7..267944f 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -270,6 +270,68 @@ export const api = { }) }, + // ═══════════════════════════════════════════════════════════════════════════ + // AI CONTROL + // ═══════════════════════════════════════════════════════════════════════════ + + async getAIControlSettings(userId: string) { + return fetchApi<{ + enabledCategories: string[] + categoryActions: Record + autoDetectCompanies: boolean + }>(`/preferences/ai-control?userId=${userId}`) + }, + + async saveAIControlSettings(userId: string, settings: { + enabledCategories?: string[] + categoryActions?: Record + autoDetectCompanies?: boolean + }) { + return fetchApi<{ success: boolean }>('/preferences/ai-control', { + method: 'POST', + body: JSON.stringify({ userId, ...settings }), + }) + }, + + // ═══════════════════════════════════════════════════════════════════════════ + // COMPANY LABELS + // ═══════════════════════════════════════════════════════════════════════════ + + async getCompanyLabels(userId: string) { + return fetchApi>(`/preferences/company-labels?userId=${userId}`) + }, + + async saveCompanyLabel(userId: string, companyLabel: { + id?: string + name: string + condition: string + enabled: boolean + category?: string + }) { + return fetchApi<{ + id?: string + name: string + condition: string + enabled: boolean + category?: string + }>('/preferences/company-labels', { + method: 'POST', + body: JSON.stringify({ userId, companyLabel }), + }) + }, + + async deleteCompanyLabel(userId: string, labelId: string) { + return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}?userId=${userId}`, { + method: 'DELETE', + }) + }, + // ═══════════════════════════════════════════════════════════════════════════ // PRODUCTS & QUESTIONS (Legacy) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/client/src/main.tsx b/client/src/main.tsx index bef5202..1dc6139 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +// #region agent log +try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.tsx:8',message:'App starting',data:{rootExists:!!document.getElementById('root')},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{}); +} catch(e) {} +// #endregion + createRoot(document.getElementById('root')!).render( diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 66eb28d..2ab7b31 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -22,9 +22,12 @@ import { Loader2, Crown, Star, + Brain, + Building2, } from 'lucide-react' +import type { AIControlSettings, CompanyLabel, CategoryInfo } from '@/types/settings' -type TabType = 'profile' | 'accounts' | 'vip' | 'rules' | 'subscription' +type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'subscription' interface EmailAccount { id: string @@ -39,14 +42,6 @@ interface VIPSender { name?: string } -interface SortRule { - id: string - name: string - condition: string - category: string - enabled: boolean -} - interface Subscription { status: string plan: string @@ -55,11 +50,23 @@ interface Subscription { } export function Settings() { + // #region agent log + try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'Settings.tsx:52',message:'Settings component rendering',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{}); + } catch(e) {} + // #endregion + const { user } = useAuth() const navigate = useNavigate() const [searchParams, setSearchParams] = useSearchParams() const activeTab = (searchParams.get('tab') as TabType) || 'profile' + + // #region agent log + try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'Settings.tsx:60',message:'Settings state initialized',data:{hasUser:!!user,activeTab},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{}); + } catch(e) {} + // #endregion const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) @@ -70,31 +77,79 @@ export function Settings() { const [connectingProvider, setConnectingProvider] = useState(null) const [vipSenders, setVipSenders] = useState([]) const [newVipEmail, setNewVipEmail] = useState('') - const [rules, setRules] = useState([ - { id: '1', name: 'Boss Emails', condition: 'from:boss@company.com', category: 'Important', enabled: true }, - { id: '2', name: 'Support Tickets', condition: 'subject:Ticket #', category: 'Clients', enabled: true }, - ]) const [subscription, setSubscription] = useState(null) + + // AI Control state + const [aiControlSettings, setAiControlSettings] = useState({ + enabledCategories: [], + categoryActions: {}, + autoDetectCompanies: true, + }) + const [categories, setCategories] = useState([]) + const [companyLabels, setCompanyLabels] = useState([]) + const [newCompanyLabel, setNewCompanyLabel] = useState({ name: '', condition: '', category: 'promotions' }) useEffect(() => { loadData() }, [user]) const loadData = async () => { + // #region agent log + try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'Settings.tsx:84',message:'loadData called',data:{hasUser:!!user,userId:user?.$id},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{}); + } catch(e) {} + // #endregion + if (!user?.$id) return setLoading(true) try { - const [accountsRes, subsRes, prefsRes] = await Promise.all([ + const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes] = await Promise.all([ api.getEmailAccounts(user.$id), api.getSubscriptionStatus(user.$id), api.getUserPreferences(user.$id), + api.getAIControlSettings(user.$id), + api.getCompanyLabels(user.$id), ]) if (accountsRes.data) setAccounts(accountsRes.data) if (subsRes.data) setSubscription(subsRes.data) if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders) + if (aiControlRes.data) setAiControlSettings(aiControlRes.data) + if (companyLabelsRes.data) setCompanyLabels(companyLabelsRes.data) + + // Load categories from API or use defaults + const categoryList: CategoryInfo[] = [ + { key: 'vip', name: 'Important', description: 'Important emails from known contacts', defaultAction: 'star', color: '#ff0000', enabled: true }, + { key: 'customers', name: 'Clients', description: 'Emails from clients and projects', defaultAction: 'inbox', color: '#4285f4', enabled: true }, + { key: 'invoices', name: 'Invoices', description: 'Invoices, receipts and financial documents', defaultAction: 'inbox', color: '#0f9d58', enabled: true }, + { key: 'newsletters', name: 'Newsletter', description: 'Regular newsletters and updates', defaultAction: 'archive_read', color: '#9c27b0', enabled: true }, + { key: 'promotions', name: 'Promotions', description: 'Marketing emails and promotions', defaultAction: 'archive_read', color: '#ff9800', enabled: true }, + { key: 'social', name: 'Social', description: 'Social media and platform notifications', defaultAction: 'archive_read', color: '#00bcd4', enabled: true }, + { key: 'security', name: 'Security', description: 'Security codes and notifications', defaultAction: 'inbox', color: '#f44336', enabled: true }, + { key: 'calendar', name: 'Calendar', description: 'Calendar invites and events', defaultAction: 'inbox', color: '#673ab7', enabled: true }, + { key: 'review', name: 'Review', description: 'Emails that need manual review', defaultAction: 'inbox', color: '#607d8b', enabled: true }, + ] + + // Update enabled status from settings + const enabledCategories = aiControlRes.data?.enabledCategories || categoryList.map(c => c.key) + const updatedCategories = categoryList.map(cat => ({ + ...cat, + enabled: enabledCategories.includes(cat.key), + })) + setCategories(updatedCategories) + + // #region agent log + try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'Settings.tsx:122',message:'loadData success',data:{accountsCount:accountsRes.data?.length||0,categoriesCount:updatedCategories.length},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{}); + } catch(e) {} + // #endregion } catch (error) { + // #region agent log + try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'Settings.tsx:123',message:'loadData error',data:{errorMessage:error instanceof Error?error.message:String(error),errorStack:error instanceof Error?error.stack:undefined},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{}); + } catch(e) {} + // #endregion console.error('Failed to load settings data:', error) } finally { setLoading(false) @@ -180,12 +235,6 @@ export function Settings() { } } - const toggleRule = (ruleId: string) => { - setRules(rules.map(r => - r.id === ruleId ? { ...r, enabled: !r.enabled } : r - )) - } - const handleManageSubscription = async () => { if (!user?.$id) return @@ -216,10 +265,16 @@ export function Settings() { { id: 'profile' as TabType, label: 'Profile', icon: User }, { id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail }, { id: 'vip' as TabType, label: 'VIP List', icon: Star }, - { id: 'rules' as TabType, label: 'Sorting Rules', icon: SettingsIcon }, + { id: 'ai-control' as TabType, label: 'AI Control', icon: Brain }, { id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard }, ] + // #region agent log + try { + fetch('http://127.0.0.1:7242/ingest/4fa7412d-6f79-4871-8728-29c37c9e5772',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'Settings.tsx:243',message:'Settings render starting',data:{loading,activeTab,hasUser:!!user},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{}); + } catch(e) {} + // #endregion + return (
@@ -463,41 +518,335 @@ export function Settings() { )} - {activeTab === 'rules' && ( - - - Sorting Rules - Custom rules for email sorting - - - {rules.map((rule) => ( -
-
- -
-

{rule.name}

-

{rule.condition}

-
+ {activeTab === 'ai-control' && ( +
+ {/* Category Toggles */} + + +
+
+ + + Category Control + + Enable or disable email categories for AI sorting
- {rule.category} +
- ))} +
+ + {categories.map((category) => ( +
+
+ +
+
+
+

+ {category.name} +

+
+

{category.description}

+
+
+ {category.enabled && ( + + )} +
+ ))} + + - - - + {/* Company Labels */} + + +
+
+ + + Company Labels + + Automatically label emails from specific companies +
+ +
+
+ + {/* Auto-Detection Toggle */} +
+
+

Auto-Detect Known Companies

+

Automatically detect and label emails from Amazon, Google, Microsoft, etc. Labels are created automatically when emails from these companies are detected.

+
+ +
+ + {/* Custom Company Labels - Optional */} + {companyLabels.length > 0 && ( +
+

Custom Company Labels (Optional)

+ + {/* Add New Label Form */} +
+
+ + setNewCompanyLabel({ ...newCompanyLabel, name: e.target.value })} + /> +
+
+ + setNewCompanyLabel({ ...newCompanyLabel, condition: e.target.value })} + /> +

Use "from:domain.com" or "subject:keyword"

+
+
+ + +
+ +
+ + {/* Existing Labels */} +
+ {companyLabels.map((label) => ( +
+
+
+

{label.name}

+ + {label.enabled ? 'Enabled' : 'Disabled'} + +
+

{label.condition}

+ {label.category && ( +

Category: {label.category}

+ )} +
+
+ + +
+
+ ))} +
+
+ )} + + {/* Add Custom Label - Collapsible */} +
+ +
+ Add Custom Company Label (Optional) + +
+
+
+
+ + setNewCompanyLabel({ ...newCompanyLabel, name: e.target.value })} + /> +
+
+ + setNewCompanyLabel({ ...newCompanyLabel, condition: e.target.value })} + /> +

Use "from:domain.com" or "subject:keyword"

+
+
+ + +
+ +
+
+
+
+
)} {activeTab === 'subscription' && ( diff --git a/client/src/types/settings.ts b/client/src/types/settings.ts new file mode 100644 index 0000000..8ce34b0 --- /dev/null +++ b/client/src/types/settings.ts @@ -0,0 +1,32 @@ +/** + * TypeScript types for Settings and AI Control + */ + +export interface AIControlSettings { + enabledCategories: string[] + categoryActions: Record + autoDetectCompanies: boolean +} + +export interface CompanyLabel { + id?: string + name: string + condition: string + enabled: boolean + category?: string +} + +export interface CategoryInfo { + key: string + name: string + description: string + defaultAction: 'inbox' | 'archive_read' | 'star' + color: string + enabled: boolean +} + +export interface KnownCompany { + name: string + domain: string + enabled: boolean +} diff --git a/server/index.mjs b/server/index.mjs index 17e0ed2..3b14418 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -106,6 +106,105 @@ app.post('/api/preferences', asyncHandler(async (req, res) => { respond.success(res, null, 'Einstellungen gespeichert') })) +/** + * GET /api/preferences/ai-control + * Get AI Control settings + */ +app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => { + const { userId } = req.query + if (!userId) throw new ValidationError('userId is required') + + const prefs = await userPreferences.getByUser(userId) + const preferences = prefs?.preferences || userPreferences.getDefaults() + + respond.success(res, { + enabledCategories: preferences.enabledCategories || [], + categoryActions: preferences.categoryActions || {}, + autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : true, + }) +})) + +/** + * POST /api/preferences/ai-control + * Save AI Control settings + */ +app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => { + const { userId, enabledCategories, categoryActions, autoDetectCompanies } = req.body + if (!userId) throw new ValidationError('userId is required') + + const updates = {} + if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories + if (categoryActions !== undefined) updates.categoryActions = categoryActions + if (autoDetectCompanies !== undefined) updates.autoDetectCompanies = autoDetectCompanies + + await userPreferences.upsert(userId, updates) + respond.success(res, null, 'AI Control settings saved') +})) + +/** + * GET /api/preferences/company-labels + * Get company labels + */ +app.get('/api/preferences/company-labels', asyncHandler(async (req, res) => { + const { userId } = req.query + if (!userId) throw new ValidationError('userId is required') + + const prefs = await userPreferences.getByUser(userId) + const preferences = prefs?.preferences || userPreferences.getDefaults() + + respond.success(res, preferences.companyLabels || []) +})) + +/** + * POST /api/preferences/company-labels + * Save/Update company label + */ +app.post('/api/preferences/company-labels', asyncHandler(async (req, res) => { + const { userId, companyLabel } = req.body + if (!userId) throw new ValidationError('userId is required') + if (!companyLabel) throw new ValidationError('companyLabel is required') + + const prefs = await userPreferences.getByUser(userId) + const preferences = prefs?.preferences || userPreferences.getDefaults() + + const companyLabels = preferences.companyLabels || [] + + // Generate ID if not provided + if (!companyLabel.id) { + companyLabel.id = `label_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + } + + // Update or add label + const existingIndex = companyLabels.findIndex(l => l.id === companyLabel.id) + if (existingIndex >= 0) { + companyLabels[existingIndex] = companyLabel + } else { + companyLabels.push(companyLabel) + } + + await userPreferences.upsert(userId, { companyLabels }) + respond.success(res, companyLabel, 'Company label saved') +})) + +/** + * DELETE /api/preferences/company-labels/:id + * Delete company label + */ +app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res) => { + const { userId } = req.query + const { id } = req.params + if (!userId) throw new ValidationError('userId is required') + if (!id) throw new ValidationError('label id is required') + + const prefs = await userPreferences.getByUser(userId) + const preferences = prefs?.preferences || userPreferences.getDefaults() + + const companyLabels = (preferences.companyLabels || []).filter(l => l.id !== id) + + await userPreferences.upsert(userId, { companyLabels }) + respond.success(res, null, 'Company label deleted') +})) + // Legacy Stripe webhook endpoint app.use('/stripe', stripeRoutes) diff --git a/server/routes/email.mjs b/server/routes/email.mjs index 7802bad..2014412 100644 --- a/server/routes/email.mjs +++ b/server/routes/email.mjs @@ -379,11 +379,17 @@ router.post('/sort', log.success(`${deletedLabels} old labels cleaned up`) } - // Create labels only for categories without native Gmail category + // Create labels for categories and company labels const categories = sorter.getCategories() const labelMap = {} + const companyLabelMap = {} + // Create labels for enabled categories only + const enabledCategories = sorter.getEnabledCategories(preferences) for (const [key, cat] of Object.entries(categories)) { + // Skip disabled categories + if (!enabledCategories.includes(key)) continue + // Wenn Gmail-Kategorie existiert, diese verwenden const gmailCat = sorter.getGmailCategory(key) if (gmailCat) { @@ -401,6 +407,38 @@ router.post('/sort', } } + // Create company labels + if (preferences.companyLabels?.length) { + for (const companyLabel of preferences.companyLabels) { + if (!companyLabel.enabled) continue + + try { + // Use orange color for company labels + const label = await gmail.createLabel(companyLabel.name, '#ff9800') + if (label) { + companyLabelMap[companyLabel.id || companyLabel.name] = label.id + } + } catch (err) { + log.warn(`Failed to create company label: ${companyLabel.name}`) + } + } + } + + // Create auto-detected company labels if enabled + if (preferences.autoDetectCompanies) { + const knownCompanies = ['Amazon', 'Google', 'Microsoft', 'Apple', 'Facebook', 'Twitter', 'LinkedIn', 'GitHub', 'Netflix', 'Spotify', 'PayPal', 'Stripe', 'Shopify', 'Uber', 'Airbnb', 'Dropbox', 'Slack', 'Zoom'] + for (const companyName of knownCompanies) { + try { + const label = await gmail.createLabel(companyName, '#ff9800') + if (label) { + companyLabelMap[companyName] = label.id + } + } catch (err) { + // Label might already exist, ignore + } + } + } + // Fetch and process ALL emails with pagination let pageToken = null let totalProcessed = 0 @@ -420,27 +458,76 @@ router.post('/sort', // Get full email details const emails = await gmail.batchGetEmails(messages.map(m => m.id)) - // Classify emails with AI - const classified = await sorter.batchCategorize( - emails.map(e => ({ - from: e.headers?.from || '', - subject: e.headers?.subject || '', - snippet: e.snippet || '', - })), - preferences - ) + // Process each email: check company labels first, then AI categorization + const processedEmails = [] + + for (const email of emails) { + const emailData = { + from: email.headers?.from || '', + subject: email.headers?.subject || '', + snippet: email.snippet || '', + } + + let category = null + let companyLabel = null + let skipAI = false + + // PRIORITY 1: Check custom company labels + if (preferences.companyLabels?.length) { + for (const companyLabelConfig of preferences.companyLabels) { + if (!companyLabelConfig.enabled) continue + + if (sorter.matchesCompanyLabel(emailData, companyLabelConfig)) { + category = companyLabelConfig.category || 'promotions' + companyLabel = companyLabelConfig.name + skipAI = true + break + } + } + } + + // PRIORITY 2: Check auto-detected companies + if (!skipAI && preferences.autoDetectCompanies) { + const detected = sorter.detectCompany(emailData) + if (detected) { + category = 'promotions' // Default category for companies + companyLabel = detected.label + skipAI = true + } + } + + // PRIORITY 3: AI categorization (if no company label matched) + if (!skipAI) { + const classification = await sorter.categorize(emailData, preferences) + category = classification.category + + // If category is disabled, fallback to review + if (!enabledCategories.includes(category)) { + category = 'review' + } + } + + processedEmails.push({ + email, + category, + companyLabel, + }) + } // Apply labels/categories and actions - for (let i = 0; i < classified.length; i++) { - const email = emails[i] - const { category } = classified[i].classification - const action = sorter.getCategoryAction(category) + for (const { email, category, companyLabel } of processedEmails) { + const action = sorter.getCategoryAction(category, preferences) try { const labelsToAdd = [] const labelsToRemove = [] - // Add label/category + // Add company label if matched + if (companyLabel && companyLabelMap[companyLabel]) { + labelsToAdd.push(companyLabelMap[companyLabel]) + } + + // Add category label/category if (labelMap[category]) { labelsToAdd.push(labelMap[category]) } @@ -533,24 +620,75 @@ router.post('/sort', if (!messages?.length) break - // Classify emails with AI - const classified = await sorter.batchCategorize( - messages.map(e => ({ - from: e.from?.emailAddress?.address || '', - subject: e.subject || '', - snippet: e.bodyPreview || '', - })), - preferences - ) + // Process each email: check company labels first, then AI categorization + const enabledCategories = sorter.getEnabledCategories(preferences) + const processedEmails = [] + + for (const email of messages) { + const emailData = { + from: email.from?.emailAddress?.address || '', + subject: email.subject || '', + snippet: email.bodyPreview || '', + } + + let category = null + let companyLabel = null + let skipAI = false + + // PRIORITY 1: Check custom company labels + if (preferences.companyLabels?.length) { + for (const companyLabelConfig of preferences.companyLabels) { + if (!companyLabelConfig.enabled) continue + + if (sorter.matchesCompanyLabel(emailData, companyLabelConfig)) { + category = companyLabelConfig.category || 'promotions' + companyLabel = companyLabelConfig.name + skipAI = true + break + } + } + } + + // PRIORITY 2: Check auto-detected companies + if (!skipAI && preferences.autoDetectCompanies) { + const detected = sorter.detectCompany(emailData) + if (detected) { + category = 'promotions' // Default category for companies + companyLabel = detected.label + skipAI = true + } + } + + // PRIORITY 3: AI categorization (if no company label matched) + if (!skipAI) { + const classification = await sorter.categorize(emailData, preferences) + category = classification.category + + // If category is disabled, fallback to review + if (!enabledCategories.includes(category)) { + category = 'review' + } + } + + processedEmails.push({ + email, + category, + companyLabel, + }) + } // Apply categories and actions - for (let i = 0; i < classified.length; i++) { - const email = messages[i] - const { category } = classified[i].classification - const action = sorter.getCategoryAction(category) + for (const { email, category, companyLabel } of processedEmails) { + const action = sorter.getCategoryAction(category, preferences) const catName = sorter.getLabelName(category) try { + // Add company label as category if matched + if (companyLabel) { + await outlook.addCategories(email.id, [companyLabel]) + } + + // Add category await outlook.addCategories(email.id, [catName]) // Handle different actions diff --git a/server/services/ai-sorter.mjs b/server/services/ai-sorter.mjs index 23b0710..81807e4 100644 --- a/server/services/ai-sorter.mjs +++ b/server/services/ai-sorter.mjs @@ -91,6 +91,40 @@ const CATEGORIES = { }, } +/** + * Known companies for automatic detection + * Maps domain patterns to company names + */ +const KNOWN_COMPANIES = { + 'amazon.com': 'Amazon', + 'amazon.de': 'Amazon', + 'amazon.co.uk': 'Amazon', + 'amazon.fr': 'Amazon', + 'google.com': 'Google', + 'gmail.com': 'Google', + 'microsoft.com': 'Microsoft', + 'outlook.com': 'Microsoft', + 'hotmail.com': 'Microsoft', + 'apple.com': 'Apple', + 'icloud.com': 'Apple', + 'facebook.com': 'Facebook', + 'meta.com': 'Meta', + 'twitter.com': 'Twitter', + 'x.com': 'Twitter', + 'linkedin.com': 'LinkedIn', + 'github.com': 'GitHub', + 'netflix.com': 'Netflix', + 'spotify.com': 'Spotify', + 'paypal.com': 'PayPal', + 'stripe.com': 'Stripe', + 'shopify.com': 'Shopify', + 'uber.com': 'Uber', + 'airbnb.com': 'Airbnb', + 'dropbox.com': 'Dropbox', + 'slack.com': 'Slack', + 'zoom.us': 'Zoom', +} + /** * AI Sorter Service Class */ @@ -137,9 +171,14 @@ export class AISorterService { } /** - * Get action for category + * Get action for category (respects user preferences) */ - getCategoryAction(key) { + getCategoryAction(key, preferences = {}) { + // Check for user override first + if (preferences.categoryActions?.[key]) { + return preferences.categoryActions[key] + } + // Return default action return CATEGORIES[key]?.action || 'inbox' } @@ -150,6 +189,93 @@ export class AISorterService { return CATEGORIES[key]?.color || '#607d8b' } + /** + * Get enabled categories based on user preferences + */ + getEnabledCategories(preferences = {}) { + const enabled = preferences.enabledCategories || Object.keys(CATEGORIES) + return enabled.filter(key => CATEGORIES[key]) // Only return valid categories + } + + /** + * Detect company from email address + */ + detectCompany(email) { + if (!email?.from) return null + + // Extract domain from email + const emailMatch = email.from.match(/@([^\s>]+)/) + if (!emailMatch) return null + + const domain = emailMatch[1].toLowerCase() + + // Check known companies + if (KNOWN_COMPANIES[domain]) { + return { + name: KNOWN_COMPANIES[domain], + domain, + label: KNOWN_COMPANIES[domain], + } + } + + // Check for subdomains (e.g., mail.amazon.com -> Amazon) + const domainParts = domain.split('.') + if (domainParts.length > 2) { + const baseDomain = domainParts.slice(-2).join('.') + if (KNOWN_COMPANIES[baseDomain]) { + return { + name: KNOWN_COMPANIES[baseDomain], + domain: baseDomain, + label: KNOWN_COMPANIES[baseDomain], + } + } + } + + return null + } + + /** + * Check if email matches a company label condition + */ + matchesCompanyLabel(email, companyLabel) { + if (!companyLabel?.enabled || !companyLabel?.condition) return false + + const { condition } = companyLabel + const from = email.from?.toLowerCase() || '' + const subject = email.subject?.toLowerCase() || '' + + // Simple condition parser: supports "from:domain.com" and "subject:keyword" + if (condition.includes('from:')) { + const domain = condition.split('from:')[1]?.trim().split(' ')[0] + if (domain && from.includes(domain)) return true + } + + if (condition.includes('subject:')) { + const keyword = condition.split('subject:')[1]?.trim().split(' ')[0] + if (keyword && subject.includes(keyword)) return true + } + + // Support OR conditions + if (condition.includes(' OR ')) { + const parts = condition.split(' OR ') + return parts.some(part => this.matchesCompanyLabel(email, { ...companyLabel, condition: part.trim() })) + } + + // Support AND conditions + if (condition.includes(' AND ')) { + const parts = condition.split(' AND ') + return parts.every(part => this.matchesCompanyLabel(email, { ...companyLabel, condition: part.trim() })) + } + + // Simple domain match + if (condition.includes('@')) { + const domain = condition.split('@')[1]?.trim() + if (domain && from.includes(domain)) return true + } + + return false + } + /** * Categorize a single email */ @@ -305,6 +431,14 @@ Respond ONLY with the JSON array.` _buildPreferenceContext(preferences) { const parts = [] + // Get enabled categories + const enabledCategories = this.getEnabledCategories(preferences) + if (enabledCategories.length < Object.keys(CATEGORIES).length) { + const disabled = Object.keys(CATEGORIES).filter(k => !enabledCategories.includes(k)) + parts.push(`DISABLED CATEGORIES (do not use): ${disabled.map(k => CATEGORIES[k].name).join(', ')}`) + parts.push(`ONLY USE THESE CATEGORIES: ${enabledCategories.map(k => `${k} (${CATEGORIES[k].name})`).join(', ')}`) + } + if (preferences.vipSenders?.length) { parts.push(`VIP Senders (always categorize as "vip"): ${preferences.vipSenders.join(', ')}`) } @@ -321,6 +455,14 @@ Respond ONLY with the JSON array.` parts.push(`Priority Topics (higher priority): ${preferences.priorityTopics.join(', ')}`) } + // Company labels context + if (preferences.companyLabels?.length) { + const activeLabels = preferences.companyLabels.filter(l => l.enabled) + if (activeLabels.length > 0) { + parts.push(`Company Labels (apply these labels when conditions match):\n${activeLabels.map(l => `- ${l.name}: ${l.condition} → ${l.category || 'promotions'}`).join('\n')}`) + } + } + return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : '' } diff --git a/server/services/database.mjs b/server/services/database.mjs index fbc5f9e..02014d5 100644 --- a/server/services/database.mjs +++ b/server/services/database.mjs @@ -286,14 +286,44 @@ export const subscriptions = { * User preferences operations */ export const userPreferences = { + /** + * Get default preferences structure + */ + getDefaults() { + return { + vipSenders: [], + enabledCategories: ['vip', 'customers', 'invoices', 'newsletters', 'promotions', 'social', 'security', 'calendar', 'review'], + categoryActions: {}, + companyLabels: [], + autoDetectCompanies: true, + } + }, + + /** + * Merge preferences with defaults + */ + mergeWithDefaults(preferences) { + const defaults = this.getDefaults() + return { + ...defaults, + ...preferences, + vipSenders: preferences.vipSenders || defaults.vipSenders, + enabledCategories: preferences.enabledCategories || defaults.enabledCategories, + categoryActions: preferences.categoryActions || defaults.categoryActions, + companyLabels: preferences.companyLabels || defaults.companyLabels, + autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies, + } + }, + async getByUser(userId) { const pref = await db.findOne(Collections.USER_PREFERENCES, [ Query.equal('userId', userId), ]) if (pref?.preferencesJson) { - return { ...pref, preferences: JSON.parse(pref.preferencesJson) } + const parsed = JSON.parse(pref.preferencesJson) + return { ...pref, preferences: this.mergeWithDefaults(parsed) } } - return pref + return { ...pref, preferences: this.getDefaults() } }, async upsert(userId, preferences) { @@ -301,7 +331,14 @@ export const userPreferences = { Query.equal('userId', userId), ]) - const data = { preferencesJson: JSON.stringify(preferences) } + // Merge with existing preferences if updating + let mergedPreferences = preferences + if (existing?.preferencesJson) { + const existingPrefs = JSON.parse(existing.preferencesJson) + mergedPreferences = { ...existingPrefs, ...preferences } + } + + const data = { preferencesJson: JSON.stringify(mergedPreferences) } if (existing) { return db.update(Collections.USER_PREFERENCES, existing.$id, data)