/** * AI Email Sorter Service * Uses Mistral AI for intelligent email categorization */ import { Mistral } from '@mistralai/mistralai' import { config } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' /** * Email categories with metadata * Uses Gmail categories where available * * Actions: * - 'star': Keep in inbox + add star (VIP) * - 'inbox': Keep in inbox * - 'archive_read': Archive + mark as read (cleans inbox) */ const CATEGORIES = { vip: { name: 'Important', description: 'Important emails from known contacts', color: '#ff0000', gmailCategory: null, action: 'star', // Keep in inbox + star priority: 1, }, customers: { name: 'Clients', description: 'Emails from clients and projects', color: '#4285f4', gmailCategory: null, action: 'inbox', // Keep in inbox priority: 2, }, invoices: { name: 'Invoices', description: 'Invoices, receipts and financial documents', color: '#0f9d58', gmailCategory: null, action: 'inbox', // Keep in inbox priority: 3, }, newsletters: { name: 'Newsletter', description: 'Regular newsletters and updates', color: '#9c27b0', gmailCategory: 'CATEGORY_UPDATES', action: 'archive_read', // Archive + mark as read priority: 4, }, promotions: { name: 'Promotions', description: 'Marketing emails and promotions', color: '#ff9800', gmailCategory: 'CATEGORY_PROMOTIONS', action: 'archive_read', // Archive + mark as read priority: 5, }, social: { name: 'Social', description: 'Social media and platform notifications', color: '#00bcd4', gmailCategory: 'CATEGORY_SOCIAL', action: 'archive_read', // Archive + mark as read priority: 6, }, security: { name: 'Security', description: 'Security codes and notifications', color: '#f44336', gmailCategory: null, action: 'inbox', // Keep in inbox (important!) priority: 1, }, calendar: { name: 'Calendar', description: 'Calendar invites and events', color: '#673ab7', gmailCategory: null, action: 'inbox', // Keep in inbox priority: 3, }, review: { name: 'Review', description: 'Emails that need manual review', color: '#607d8b', gmailCategory: null, action: 'inbox', // Keep in inbox for review priority: 10, }, } /** * AI Sorter Service Class */ export class AISorterService { constructor() { this.client = null this.model = 'mistral-small-latest' this.enabled = Boolean(config.mistral.apiKey) if (this.enabled) { this.client = new Mistral({ apiKey: config.mistral.apiKey }) log.info('AI Sorter Service initialized with Mistral AI') } else { log.warn('AI Sorter Service disabled - no MISTRAL_API_KEY') } } /** * Get all categories */ getCategories() { return CATEGORIES } /** * Get category names as array */ getCategoryNames() { return Object.values(CATEGORIES).map(c => c.name) } /** * Get label name for a category key */ getLabelName(key) { return CATEGORIES[key]?.name || CATEGORIES.review.name } /** * Get Gmail category ID if available */ getGmailCategory(key) { return CATEGORIES[key]?.gmailCategory || null } /** * Get action for category */ getCategoryAction(key) { return CATEGORIES[key]?.action || 'inbox' } /** * Get color for category */ getCategoryColor(key) { return CATEGORIES[key]?.color || '#607d8b' } /** * Categorize a single email */ async categorize(email, preferences = {}) { if (!this.enabled) { return { category: 'review', confidence: 0, reason: 'AI not configured' } } const { from, subject, snippet } = email // Build context from preferences const preferenceContext = this._buildPreferenceContext(preferences) const prompt = `You are an intelligent email sorting assistant. Analyze the following email and categorize it. AVAILABLE CATEGORIES: ${Object.entries(CATEGORIES).map(([key, cat]) => `- ${key}: ${cat.name} - ${cat.description}`).join('\n')} ${preferenceContext} EMAIL: From: ${from} Subject: ${subject} Preview: ${snippet?.substring(0, 500) || 'No preview'} RESPONSE FORMAT (JSON ONLY): {"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation"} Respond ONLY with the JSON object.` try { const response = await this.client.chat.complete({ model: this.model, messages: [{ role: 'user', content: prompt }], temperature: 0.1, maxTokens: 150, responseFormat: { type: 'json_object' }, }) const content = response.choices[0]?.message?.content const result = JSON.parse(content) // Validate category if (!CATEGORIES[result.category]) { result.category = 'review' } return result } catch (error) { log.error('AI categorization failed', { error: error.message }) return { category: 'review', confidence: 0, reason: 'Categorization error' } } } /** * Batch categorize multiple emails */ async batchCategorize(emails, preferences = {}) { if (!this.enabled || emails.length === 0) { return emails.map(e => ({ email: e, classification: { category: 'review', confidence: 0, reason: 'AI not available' }, })) } // For small batches, process individually if (emails.length <= 5) { const results = [] for (const email of emails) { const classification = await this.categorize(email, preferences) results.push({ email, classification }) } return results } // For larger batches, use batch request const preferenceContext = this._buildPreferenceContext(preferences) const emailList = emails.map((e, i) => `[${i}] From: ${e.from} | Subject: ${e.subject} | Preview: ${e.snippet?.substring(0, 200) || '-'}` ).join('\n') const prompt = `You are an email sorting assistant. Categorize the following ${emails.length} emails. CATEGORIES: ${Object.entries(CATEGORIES).map(([key, cat]) => `${key}: ${cat.name}`).join(' | ')} ${preferenceContext} EMAILS: ${emailList} RESPONSE FORMAT (JSON ARRAY ONLY): [{"index": 0, "category": "key"}, {"index": 1, "category": "key"}, ...] Respond ONLY with the JSON array.` try { const response = await this.client.chat.complete({ model: this.model, messages: [{ role: 'user', content: prompt }], temperature: 0.1, maxTokens: emails.length * 50, responseFormat: { type: 'json_object' }, }) const content = response.choices[0]?.message?.content let parsed // Handle both array and object responses try { parsed = JSON.parse(content) if (parsed.results) parsed = parsed.results if (!Array.isArray(parsed)) { throw new Error('Not an array') } } catch { // Fallback to individual processing return this._fallbackBatch(emails, preferences) } return emails.map((email, i) => { const result = parsed.find(r => r.index === i) const category = result?.category && CATEGORIES[result.category] ? result.category : 'review' return { email, classification: { category, confidence: 0.8, reason: 'Batch' }, } }) } catch (error) { log.error('Batch categorization failed', { error: error.message }) return this._fallbackBatch(emails, preferences) } } /** * Fallback to individual categorization */ async _fallbackBatch(emails, preferences) { const results = [] for (const email of emails) { const classification = await this.categorize(email, preferences) results.push({ email, classification }) // Rate limiting pause await new Promise(r => setTimeout(r, 100)) } return results } /** * Build preference context for prompt */ _buildPreferenceContext(preferences) { const parts = [] if (preferences.vipSenders?.length) { parts.push(`VIP Senders (always categorize as "vip"): ${preferences.vipSenders.join(', ')}`) } if (preferences.blockedSenders?.length) { parts.push(`Blocked Senders (categorize as "promotions"): ${preferences.blockedSenders.join(', ')}`) } if (preferences.customRules?.length) { parts.push(`Custom Rules:\n${preferences.customRules.map(r => `- ${r.condition}: ${r.category}`).join('\n')}`) } if (preferences.priorityTopics?.length) { parts.push(`Priority Topics (higher priority): ${preferences.priorityTopics.join(', ')}`) } return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : '' } /** * Learn from user corrections */ async learnFromCorrection(email, originalCategory, correctedCategory, userId) { log.info('Learning correction received', { from: email.from, original: originalCategory, corrected: correctedCategory, userId, }) // In production, this would: // 1. Store correction in database // 2. Update user preferences // 3. Potentially fine-tune the model return { learned: true } } /** * Get category statistics */ getCategoryStats(classifications) { const stats = {} for (const { classification } of classifications) { const cat = classification.category stats[cat] = (stats[cat] || 0) + 1 } return stats } } // Default export export default AISorterService