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:
2026-01-26 17:49:39 +01:00
parent 6ba5563d54
commit 18c11d27bc
9 changed files with 963 additions and 86 deletions

View File

@@ -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` : ''
}

View File

@@ -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)