feat: AI Control Settings mit Category Control und Company Labels
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 </div> 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
This commit is contained in:
@@ -17,6 +17,12 @@ import { Imprint } from '@/pages/Imprint'
|
|||||||
// Initialize analytics on app startup
|
// Initialize analytics on app startup
|
||||||
initAnalytics()
|
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
|
// Loading spinner component
|
||||||
function LoadingSpinner() {
|
function LoadingSpinner() {
|
||||||
return (
|
return (
|
||||||
@@ -63,6 +69,12 @@ function AppRoutes() {
|
|||||||
// Track page views on route changes
|
// Track page views on route changes
|
||||||
usePageTracking()
|
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 (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public pages */}
|
{/* Public pages */}
|
||||||
|
|||||||
@@ -270,6 +270,68 @@ export const api = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// AI CONTROL
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async getAIControlSettings(userId: string) {
|
||||||
|
return fetchApi<{
|
||||||
|
enabledCategories: string[]
|
||||||
|
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||||
|
autoDetectCompanies: boolean
|
||||||
|
}>(`/preferences/ai-control?userId=${userId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveAIControlSettings(userId: string, settings: {
|
||||||
|
enabledCategories?: string[]
|
||||||
|
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||||
|
autoDetectCompanies?: boolean
|
||||||
|
}) {
|
||||||
|
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId, ...settings }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// COMPANY LABELS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async getCompanyLabels(userId: string) {
|
||||||
|
return fetchApi<Array<{
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
condition: string
|
||||||
|
enabled: boolean
|
||||||
|
category?: string
|
||||||
|
}>>(`/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)
|
// PRODUCTS & QUESTIONS (Legacy)
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -22,9 +22,12 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Crown,
|
Crown,
|
||||||
Star,
|
Star,
|
||||||
|
Brain,
|
||||||
|
Building2,
|
||||||
} from 'lucide-react'
|
} 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 {
|
interface EmailAccount {
|
||||||
id: string
|
id: string
|
||||||
@@ -39,14 +42,6 @@ interface VIPSender {
|
|||||||
name?: string
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortRule {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
condition: string
|
|
||||||
category: string
|
|
||||||
enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Subscription {
|
interface Subscription {
|
||||||
status: string
|
status: string
|
||||||
plan: string
|
plan: string
|
||||||
@@ -55,11 +50,23 @@ interface Subscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Settings() {
|
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 { user } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
const activeTab = (searchParams.get('tab') as TabType) || 'profile'
|
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 [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
@@ -70,31 +77,79 @@ export function Settings() {
|
|||||||
const [connectingProvider, setConnectingProvider] = useState<string | null>(null)
|
const [connectingProvider, setConnectingProvider] = useState<string | null>(null)
|
||||||
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
|
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
|
||||||
const [newVipEmail, setNewVipEmail] = useState('')
|
const [newVipEmail, setNewVipEmail] = useState('')
|
||||||
const [rules, setRules] = useState<SortRule[]>([
|
|
||||||
{ 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<Subscription | null>(null)
|
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||||
|
|
||||||
|
// AI Control state
|
||||||
|
const [aiControlSettings, setAiControlSettings] = useState<AIControlSettings>({
|
||||||
|
enabledCategories: [],
|
||||||
|
categoryActions: {},
|
||||||
|
autoDetectCompanies: true,
|
||||||
|
})
|
||||||
|
const [categories, setCategories] = useState<CategoryInfo[]>([])
|
||||||
|
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
|
||||||
|
const [newCompanyLabel, setNewCompanyLabel] = useState({ name: '', condition: '', category: 'promotions' })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
const loadData = async () => {
|
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
|
if (!user?.$id) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [accountsRes, subsRes, prefsRes] = await Promise.all([
|
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes] = await Promise.all([
|
||||||
api.getEmailAccounts(user.$id),
|
api.getEmailAccounts(user.$id),
|
||||||
api.getSubscriptionStatus(user.$id),
|
api.getSubscriptionStatus(user.$id),
|
||||||
api.getUserPreferences(user.$id),
|
api.getUserPreferences(user.$id),
|
||||||
|
api.getAIControlSettings(user.$id),
|
||||||
|
api.getCompanyLabels(user.$id),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||||
if (subsRes.data) setSubscription(subsRes.data)
|
if (subsRes.data) setSubscription(subsRes.data)
|
||||||
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
|
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) {
|
} 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)
|
console.error('Failed to load settings data:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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 () => {
|
const handleManageSubscription = async () => {
|
||||||
if (!user?.$id) return
|
if (!user?.$id) return
|
||||||
|
|
||||||
@@ -216,10 +265,16 @@ export function Settings() {
|
|||||||
{ id: 'profile' as TabType, label: 'Profile', icon: User },
|
{ id: 'profile' as TabType, label: 'Profile', icon: User },
|
||||||
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
|
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
|
||||||
{ id: 'vip' as TabType, label: 'VIP List', icon: Star },
|
{ 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 },
|
{ 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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
<div className="min-h-screen bg-slate-50">
|
||||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
|
<header className="bg-white border-b border-slate-200 sticky top-0 z-40">
|
||||||
@@ -463,41 +518,335 @@ export function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'rules' && (
|
{activeTab === 'ai-control' && (
|
||||||
<Card>
|
<div className="space-y-6">
|
||||||
<CardHeader>
|
{/* Category Toggles */}
|
||||||
<CardTitle>Sorting Rules</CardTitle>
|
<Card>
|
||||||
<CardDescription>Custom rules for email sorting</CardDescription>
|
<CardHeader>
|
||||||
</CardHeader>
|
<div className="flex items-center justify-between">
|
||||||
<CardContent className="space-y-4">
|
<div>
|
||||||
{rules.map((rule) => (
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div key={rule.id} className={`flex items-center justify-between p-4 rounded-lg border ${
|
<Brain className="w-5 h-5 text-primary-500" />
|
||||||
rule.enabled ? 'bg-white border-slate-200' : 'bg-slate-50 border-slate-100'
|
Category Control
|
||||||
}`}>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-4">
|
<CardDescription>Enable or disable email categories for AI sorting</CardDescription>
|
||||||
<button
|
|
||||||
onClick={() => toggleRule(rule.id)}
|
|
||||||
className={`w-10 h-6 rounded-full transition-colors ${rule.enabled ? 'bg-primary-500' : 'bg-slate-300'}`}
|
|
||||||
>
|
|
||||||
<div className={`w-4 h-4 bg-white rounded-full transform transition-transform mx-1 ${
|
|
||||||
rule.enabled ? 'translate-x-4' : 'translate-x-0'
|
|
||||||
}`} />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<p className={`font-medium ${rule.enabled ? 'text-slate-900' : 'text-slate-500'}`}>{rule.name}</p>
|
|
||||||
<p className="text-sm text-slate-500 font-mono">{rule.condition}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={rule.enabled ? 'default' : 'secondary'}>{rule.category}</Badge>
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!user?.$id) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.saveAIControlSettings(user.$id, {
|
||||||
|
enabledCategories: aiControlSettings.enabledCategories,
|
||||||
|
categoryActions: aiControlSettings.categoryActions,
|
||||||
|
autoDetectCompanies: aiControlSettings.autoDetectCompanies,
|
||||||
|
})
|
||||||
|
showMessage('success', 'AI Control settings saved!')
|
||||||
|
} catch {
|
||||||
|
showMessage('error', 'Failed to save settings')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{categories.map((category) => (
|
||||||
|
<div key={category.key} className="flex items-center justify-between p-4 rounded-lg border border-slate-200 bg-white">
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newEnabled = category.enabled
|
||||||
|
? aiControlSettings.enabledCategories.filter(c => c !== category.key)
|
||||||
|
: [...aiControlSettings.enabledCategories, category.key]
|
||||||
|
setAiControlSettings({ ...aiControlSettings, enabledCategories: newEnabled })
|
||||||
|
setCategories(categories.map(c => c.key === category.key ? { ...c, enabled: !c.enabled } : c))
|
||||||
|
}}
|
||||||
|
className={`w-10 h-6 rounded-full transition-colors ${category.enabled ? 'bg-primary-500' : 'bg-slate-300'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 bg-white rounded-full transform transition-transform mx-1 ${
|
||||||
|
category.enabled ? 'translate-x-4' : 'translate-x-0'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: category.color }} />
|
||||||
|
<p className={`font-medium ${category.enabled ? 'text-slate-900' : 'text-slate-500'}`}>
|
||||||
|
{category.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">{category.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{category.enabled && (
|
||||||
|
<select
|
||||||
|
value={aiControlSettings.categoryActions[category.key] || category.defaultAction}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newActions = { ...aiControlSettings.categoryActions, [category.key]: e.target.value as any }
|
||||||
|
setAiControlSettings({ ...aiControlSettings, categoryActions: newActions })
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-sm border border-slate-300 rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
<option value="inbox">Keep in Inbox</option>
|
||||||
|
<option value="archive_read">Archive & Mark Read</option>
|
||||||
|
<option value="star">Star</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Button variant="outline" className="w-full">
|
{/* Company Labels */}
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Card>
|
||||||
Create new rule
|
<CardHeader>
|
||||||
</Button>
|
<div className="flex items-center justify-between">
|
||||||
</CardContent>
|
<div>
|
||||||
</Card>
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 className="w-5 h-5 text-primary-500" />
|
||||||
|
Company Labels
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Automatically label emails from specific companies</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!user?.$id) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.saveAIControlSettings(user.$id, {
|
||||||
|
autoDetectCompanies: aiControlSettings.autoDetectCompanies,
|
||||||
|
})
|
||||||
|
showMessage('success', 'Settings saved!')
|
||||||
|
} catch {
|
||||||
|
showMessage('error', 'Failed to save')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Auto-Detection Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 bg-white">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">Auto-Detect Known Companies</p>
|
||||||
|
<p className="text-sm text-slate-500">Automatically detect and label emails from Amazon, Google, Microsoft, etc. Labels are created automatically when emails from these companies are detected.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newValue = !aiControlSettings.autoDetectCompanies
|
||||||
|
setAiControlSettings({ ...aiControlSettings, autoDetectCompanies: newValue })
|
||||||
|
}}
|
||||||
|
className={`w-10 h-6 rounded-full transition-colors ${aiControlSettings.autoDetectCompanies ? 'bg-primary-500' : 'bg-slate-300'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 bg-white rounded-full transform transition-transform mx-1 ${
|
||||||
|
aiControlSettings.autoDetectCompanies ? 'translate-x-4' : 'translate-x-0'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Company Labels - Optional */}
|
||||||
|
{companyLabels.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-900">Custom Company Labels (Optional)</h3>
|
||||||
|
|
||||||
|
{/* Add New Label Form */}
|
||||||
|
<div className="p-4 rounded-lg border border-slate-200 bg-slate-50 space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="label-name">Company Name</Label>
|
||||||
|
<Input
|
||||||
|
id="label-name"
|
||||||
|
placeholder="e.g., Amazon"
|
||||||
|
value={newCompanyLabel.name}
|
||||||
|
onChange={(e) => setNewCompanyLabel({ ...newCompanyLabel, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="label-condition">Condition</Label>
|
||||||
|
<Input
|
||||||
|
id="label-condition"
|
||||||
|
placeholder="e.g., from:amazon.com OR from:amazon.de"
|
||||||
|
value={newCompanyLabel.condition}
|
||||||
|
onChange={(e) => setNewCompanyLabel({ ...newCompanyLabel, condition: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Use "from:domain.com" or "subject:keyword"</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="label-category">Category</Label>
|
||||||
|
<select
|
||||||
|
id="label-category"
|
||||||
|
value={newCompanyLabel.category}
|
||||||
|
onChange={(e) => setNewCompanyLabel({ ...newCompanyLabel, category: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
<option value="promotions">Promotions</option>
|
||||||
|
<option value="newsletters">Newsletter</option>
|
||||||
|
<option value="invoices">Invoices</option>
|
||||||
|
<option value="customers">Clients</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!user?.$id || !newCompanyLabel.name || !newCompanyLabel.condition) return
|
||||||
|
try {
|
||||||
|
const saved = await api.saveCompanyLabel(user.$id, {
|
||||||
|
...newCompanyLabel,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
if (saved.data) {
|
||||||
|
setCompanyLabels([...companyLabels, saved.data])
|
||||||
|
setNewCompanyLabel({ name: '', condition: '', category: 'promotions' })
|
||||||
|
showMessage('success', 'Company label created!')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showMessage('error', 'Failed to create label')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Company Label
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing Labels */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{companyLabels.map((label) => (
|
||||||
|
<div key={label.id} className="flex items-center justify-between p-4 rounded-lg border border-slate-200 bg-white">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-slate-900">{label.name}</p>
|
||||||
|
<Badge variant={label.enabled ? 'default' : 'secondary'}>
|
||||||
|
{label.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500 font-mono mt-1">{label.condition}</p>
|
||||||
|
{label.category && (
|
||||||
|
<p className="text-xs text-slate-400 mt-1">Category: {label.category}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!user?.$id || !label.id) return
|
||||||
|
try {
|
||||||
|
await api.saveCompanyLabel(user.$id, { ...label, enabled: !label.enabled })
|
||||||
|
setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||||||
|
showMessage('success', 'Label updated!')
|
||||||
|
} catch {
|
||||||
|
showMessage('error', 'Failed to update label')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`w-10 h-6 rounded-full transition-colors ${label.enabled ? 'bg-primary-500' : 'bg-slate-300'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 bg-white rounded-full transform transition-transform mx-1 ${
|
||||||
|
label.enabled ? 'translate-x-4' : 'translate-x-0'
|
||||||
|
}`} />
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!user?.$id || !label.id) return
|
||||||
|
try {
|
||||||
|
await api.deleteCompanyLabel(user.$id, label.id)
|
||||||
|
setCompanyLabels(companyLabels.filter(l => l.id !== label.id))
|
||||||
|
showMessage('success', 'Label deleted!')
|
||||||
|
} catch {
|
||||||
|
showMessage('error', 'Failed to delete label')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Custom Label - Collapsible */}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="cursor-pointer p-4 rounded-lg border border-slate-200 bg-slate-50 hover:bg-slate-100 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-slate-900">Add Custom Company Label (Optional)</span>
|
||||||
|
<Plus className="w-5 h-5 text-slate-500 group-open:rotate-45 transition-transform" />
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-4 p-4 rounded-lg border border-slate-200 bg-white space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="label-name">Company Name</Label>
|
||||||
|
<Input
|
||||||
|
id="label-name"
|
||||||
|
placeholder="e.g., Amazon"
|
||||||
|
value={newCompanyLabel.name}
|
||||||
|
onChange={(e) => setNewCompanyLabel({ ...newCompanyLabel, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="label-condition">Condition</Label>
|
||||||
|
<Input
|
||||||
|
id="label-condition"
|
||||||
|
placeholder="e.g., from:amazon.com OR from:amazon.de"
|
||||||
|
value={newCompanyLabel.condition}
|
||||||
|
onChange={(e) => setNewCompanyLabel({ ...newCompanyLabel, condition: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">Use "from:domain.com" or "subject:keyword"</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="label-category">Category</Label>
|
||||||
|
<select
|
||||||
|
id="label-category"
|
||||||
|
value={newCompanyLabel.category}
|
||||||
|
onChange={(e) => setNewCompanyLabel({ ...newCompanyLabel, category: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
<option value="promotions">Promotions</option>
|
||||||
|
<option value="newsletters">Newsletter</option>
|
||||||
|
<option value="invoices">Invoices</option>
|
||||||
|
<option value="customers">Clients</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
if (!user?.$id || !newCompanyLabel.name || !newCompanyLabel.condition) return
|
||||||
|
try {
|
||||||
|
const saved = await api.saveCompanyLabel(user.$id, {
|
||||||
|
...newCompanyLabel,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
if (saved.data) {
|
||||||
|
setCompanyLabels([...companyLabels, saved.data])
|
||||||
|
setNewCompanyLabel({ name: '', condition: '', category: 'promotions' })
|
||||||
|
showMessage('success', 'Company label created!')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showMessage('error', 'Failed to create label')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Company Label
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'subscription' && (
|
{activeTab === 'subscription' && (
|
||||||
|
|||||||
32
client/src/types/settings.ts
Normal file
32
client/src/types/settings.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* TypeScript types for Settings and AI Control
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AIControlSettings {
|
||||||
|
enabledCategories: string[]
|
||||||
|
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -106,6 +106,105 @@ app.post('/api/preferences', asyncHandler(async (req, res) => {
|
|||||||
respond.success(res, null, 'Einstellungen gespeichert')
|
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
|
// Legacy Stripe webhook endpoint
|
||||||
app.use('/stripe', stripeRoutes)
|
app.use('/stripe', stripeRoutes)
|
||||||
|
|
||||||
|
|||||||
@@ -379,11 +379,17 @@ router.post('/sort',
|
|||||||
log.success(`${deletedLabels} old labels cleaned up`)
|
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 categories = sorter.getCategories()
|
||||||
const labelMap = {}
|
const labelMap = {}
|
||||||
|
const companyLabelMap = {}
|
||||||
|
|
||||||
|
// Create labels for enabled categories only
|
||||||
|
const enabledCategories = sorter.getEnabledCategories(preferences)
|
||||||
for (const [key, cat] of Object.entries(categories)) {
|
for (const [key, cat] of Object.entries(categories)) {
|
||||||
|
// Skip disabled categories
|
||||||
|
if (!enabledCategories.includes(key)) continue
|
||||||
|
|
||||||
// Wenn Gmail-Kategorie existiert, diese verwenden
|
// Wenn Gmail-Kategorie existiert, diese verwenden
|
||||||
const gmailCat = sorter.getGmailCategory(key)
|
const gmailCat = sorter.getGmailCategory(key)
|
||||||
if (gmailCat) {
|
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
|
// Fetch and process ALL emails with pagination
|
||||||
let pageToken = null
|
let pageToken = null
|
||||||
let totalProcessed = 0
|
let totalProcessed = 0
|
||||||
@@ -420,27 +458,76 @@ router.post('/sort',
|
|||||||
// Get full email details
|
// Get full email details
|
||||||
const emails = await gmail.batchGetEmails(messages.map(m => m.id))
|
const emails = await gmail.batchGetEmails(messages.map(m => m.id))
|
||||||
|
|
||||||
// Classify emails with AI
|
// Process each email: check company labels first, then AI categorization
|
||||||
const classified = await sorter.batchCategorize(
|
const processedEmails = []
|
||||||
emails.map(e => ({
|
|
||||||
from: e.headers?.from || '',
|
for (const email of emails) {
|
||||||
subject: e.headers?.subject || '',
|
const emailData = {
|
||||||
snippet: e.snippet || '',
|
from: email.headers?.from || '',
|
||||||
})),
|
subject: email.headers?.subject || '',
|
||||||
preferences
|
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
|
// Apply labels/categories and actions
|
||||||
for (let i = 0; i < classified.length; i++) {
|
for (const { email, category, companyLabel } of processedEmails) {
|
||||||
const email = emails[i]
|
const action = sorter.getCategoryAction(category, preferences)
|
||||||
const { category } = classified[i].classification
|
|
||||||
const action = sorter.getCategoryAction(category)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const labelsToAdd = []
|
const labelsToAdd = []
|
||||||
const labelsToRemove = []
|
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]) {
|
if (labelMap[category]) {
|
||||||
labelsToAdd.push(labelMap[category])
|
labelsToAdd.push(labelMap[category])
|
||||||
}
|
}
|
||||||
@@ -533,24 +620,75 @@ router.post('/sort',
|
|||||||
|
|
||||||
if (!messages?.length) break
|
if (!messages?.length) break
|
||||||
|
|
||||||
// Classify emails with AI
|
// Process each email: check company labels first, then AI categorization
|
||||||
const classified = await sorter.batchCategorize(
|
const enabledCategories = sorter.getEnabledCategories(preferences)
|
||||||
messages.map(e => ({
|
const processedEmails = []
|
||||||
from: e.from?.emailAddress?.address || '',
|
|
||||||
subject: e.subject || '',
|
for (const email of messages) {
|
||||||
snippet: e.bodyPreview || '',
|
const emailData = {
|
||||||
})),
|
from: email.from?.emailAddress?.address || '',
|
||||||
preferences
|
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
|
// Apply categories and actions
|
||||||
for (let i = 0; i < classified.length; i++) {
|
for (const { email, category, companyLabel } of processedEmails) {
|
||||||
const email = messages[i]
|
const action = sorter.getCategoryAction(category, preferences)
|
||||||
const { category } = classified[i].classification
|
|
||||||
const action = sorter.getCategoryAction(category)
|
|
||||||
const catName = sorter.getLabelName(category)
|
const catName = sorter.getLabelName(category)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Add company label as category if matched
|
||||||
|
if (companyLabel) {
|
||||||
|
await outlook.addCategories(email.id, [companyLabel])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add category
|
||||||
await outlook.addCategories(email.id, [catName])
|
await outlook.addCategories(email.id, [catName])
|
||||||
|
|
||||||
// Handle different actions
|
// Handle different actions
|
||||||
|
|||||||
@@ -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
|
* 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'
|
return CATEGORIES[key]?.action || 'inbox'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +189,93 @@ export class AISorterService {
|
|||||||
return CATEGORIES[key]?.color || '#607d8b'
|
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
|
* Categorize a single email
|
||||||
*/
|
*/
|
||||||
@@ -305,6 +431,14 @@ Respond ONLY with the JSON array.`
|
|||||||
_buildPreferenceContext(preferences) {
|
_buildPreferenceContext(preferences) {
|
||||||
const parts = []
|
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) {
|
if (preferences.vipSenders?.length) {
|
||||||
parts.push(`VIP Senders (always categorize as "vip"): ${preferences.vipSenders.join(', ')}`)
|
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(', ')}`)
|
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` : ''
|
return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -286,14 +286,44 @@ export const subscriptions = {
|
|||||||
* User preferences operations
|
* User preferences operations
|
||||||
*/
|
*/
|
||||||
export const userPreferences = {
|
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) {
|
async getByUser(userId) {
|
||||||
const pref = await db.findOne(Collections.USER_PREFERENCES, [
|
const pref = await db.findOne(Collections.USER_PREFERENCES, [
|
||||||
Query.equal('userId', userId),
|
Query.equal('userId', userId),
|
||||||
])
|
])
|
||||||
if (pref?.preferencesJson) {
|
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) {
|
async upsert(userId, preferences) {
|
||||||
@@ -301,7 +331,14 @@ export const userPreferences = {
|
|||||||
Query.equal('userId', userId),
|
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) {
|
if (existing) {
|
||||||
return db.update(Collections.USER_PREFERENCES, existing.$id, data)
|
return db.update(Collections.USER_PREFERENCES, existing.$id, data)
|
||||||
|
|||||||
Reference in New Issue
Block a user