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
1087 lines
37 KiB
JavaScript
1087 lines
37 KiB
JavaScript
/**
|
|
* Email Routes
|
|
* Email account management and sorting
|
|
*/
|
|
|
|
import express from 'express'
|
|
import { asyncHandler, NotFoundError, AuthorizationError, ValidationError } from '../middleware/errorHandler.mjs'
|
|
import { validate, rules } from '../middleware/validate.mjs'
|
|
import { limiters } from '../middleware/rateLimit.mjs'
|
|
import { respond } from '../utils/response.mjs'
|
|
import { emailAccounts, emailStats, emailDigests, userPreferences } from '../services/database.mjs'
|
|
import { config, features } from '../config/index.mjs'
|
|
import { log } from '../middleware/logger.mjs'
|
|
|
|
const router = express.Router()
|
|
|
|
// Lazy load heavy services
|
|
let gmailServiceClass = null
|
|
let outlookServiceClass = null
|
|
let aiSorterInstance = null
|
|
|
|
async function getGmailService(accessToken, refreshToken) {
|
|
if (!gmailServiceClass) {
|
|
const { GmailService } = await import('../services/gmail.mjs')
|
|
gmailServiceClass = GmailService
|
|
}
|
|
return new gmailServiceClass(accessToken, refreshToken)
|
|
}
|
|
|
|
async function getOutlookService(accessToken) {
|
|
if (!outlookServiceClass) {
|
|
const { OutlookService } = await import('../services/outlook.mjs')
|
|
outlookServiceClass = OutlookService
|
|
}
|
|
return new outlookServiceClass(accessToken)
|
|
}
|
|
|
|
async function getAISorter() {
|
|
if (!aiSorterInstance) {
|
|
const { AISorterService } = await import('../services/ai-sorter.mjs')
|
|
aiSorterInstance = new AISorterService()
|
|
}
|
|
return aiSorterInstance
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// DEMO DATA - Realistic Test Emails
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const DEMO_EMAILS = [
|
|
{ from: 'boss@company.com', subject: 'Urgent: Meeting tomorrow 9 AM', snippet: 'Hi, we need to discuss the new project urgently...' },
|
|
{ from: 'billing@amazon.com', subject: 'Your invoice for Order #US123456', snippet: 'Thank you for your order. Please find your invoice attached...' },
|
|
{ from: 'newsletter@techcrunch.com', subject: 'TechCrunch Weekly: AI Revolution 2026', snippet: 'This week in Tech: OpenAI announces new model...' },
|
|
{ from: 'noreply@linkedin.com', subject: '5 new connection requests', snippet: 'You have new connection requests from: John Smith, Jane Doe...' },
|
|
{ from: 'support@stripe.com', subject: 'Payment confirmation: $49.00', snippet: 'Your payment was successfully processed...' },
|
|
{ from: 'client@acme-corp.com', subject: 'RE: Web development proposal', snippet: 'Thank you for your proposal. We have a few questions...' },
|
|
{ from: 'noreply@google.com', subject: 'Security alert: New sign-in detected', snippet: 'We detected a new sign-in from Windows Chrome...' },
|
|
{ from: 'marketing@shopify.com', subject: '50% off today only!', snippet: 'Exclusive offer for our premium customers...' },
|
|
{ from: 'team@slack.com', subject: 'New message in #general', snippet: '@Max posted a message: Hey team, how\'s it going...' },
|
|
{ from: 'calendar@gmail.com', subject: 'Invitation: Project meeting (Monday 2 PM)', snippet: 'You have been invited to a meeting...' },
|
|
{ from: 'john.doe@gmail.com', subject: 'Important: Documents needed by tomorrow', snippet: 'Hi, could you please send the documents by tomorrow morning...' },
|
|
{ from: 'billing@verizon.com', subject: 'Your phone bill January 2026', snippet: 'Dear customer, your bill is ready for download...' },
|
|
{ from: 'deals@ebay.com', subject: 'Your Watchlist: Price drops!', snippet: '3 items on your watchlist are now cheaper...' },
|
|
{ from: 'no-reply@github.com', subject: '[GitHub] Security alert: Dependabot', snippet: 'We found a potential security vulnerability in...' },
|
|
{ from: 'newsletter@wired.com', subject: 'Wired Weekly: Cloud Computing Trends', snippet: 'The most important tech news of the week...' },
|
|
{ from: 'support@bank.com', subject: 'Account statement January 2026', snippet: 'Your monthly account statement is ready...' },
|
|
{ from: 'noreply@twitter.com', subject: '@TechNews replied to your tweet', snippet: 'TechNews: "That\'s an interesting perspective..."' },
|
|
{ from: 'info@ups.com', subject: 'Your package is on the way!', snippet: 'Tracking number: 1Z999AA10123456784, Expected delivery...' },
|
|
{ from: 'team@notion.so', subject: 'New comments in your workspace', snippet: 'Anna added a comment to "Project Plan"...' },
|
|
{ from: 'service@insurance.com', subject: 'Your policy has been updated', snippet: 'Dear customer, we have made changes to your policy...' },
|
|
]
|
|
|
|
/**
|
|
* POST /api/email/connect
|
|
* Connect a new email account
|
|
*/
|
|
router.post('/connect',
|
|
validate({
|
|
body: {
|
|
userId: [rules.required('userId')],
|
|
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo'])],
|
|
email: [rules.required('email'), rules.email()],
|
|
},
|
|
}),
|
|
asyncHandler(async (req, res) => {
|
|
const { userId, provider, email, accessToken, refreshToken, expiresAt } = req.body
|
|
|
|
// Check if account already exists
|
|
const existingAccounts = await emailAccounts.getByUser(userId)
|
|
const alreadyConnected = existingAccounts.find(a => a.email === email)
|
|
|
|
if (alreadyConnected) {
|
|
throw new ValidationError('This email account is already connected', {
|
|
email: ['Already connected'],
|
|
})
|
|
}
|
|
|
|
// Create account
|
|
const account = await emailAccounts.create({
|
|
userId,
|
|
provider,
|
|
email,
|
|
accessToken: accessToken || '',
|
|
refreshToken: refreshToken || '',
|
|
expiresAt: expiresAt || 0,
|
|
isActive: true,
|
|
lastSync: null,
|
|
})
|
|
|
|
log.success(`Email account connected: ${email} (${provider})`)
|
|
|
|
respond.created(res, {
|
|
accountId: account.$id,
|
|
email: account.email,
|
|
provider: account.provider,
|
|
})
|
|
})
|
|
)
|
|
|
|
/**
|
|
* POST /api/email/connect-demo
|
|
* Connect a demo email account for testing
|
|
*/
|
|
router.post('/connect-demo',
|
|
validate({
|
|
body: {
|
|
userId: [rules.required('userId')],
|
|
},
|
|
}),
|
|
asyncHandler(async (req, res) => {
|
|
const { userId } = req.body
|
|
const demoEmail = `demo-${userId.slice(0, 8)}@emailsorter.demo`
|
|
|
|
// Check if demo account already exists
|
|
const existingAccounts = await emailAccounts.getByUser(userId)
|
|
const alreadyConnected = existingAccounts.find(a => a.provider === 'demo')
|
|
|
|
if (alreadyConnected) {
|
|
return respond.success(res, {
|
|
accountId: alreadyConnected.$id,
|
|
email: alreadyConnected.email,
|
|
provider: 'demo',
|
|
message: 'Demo account already connected',
|
|
})
|
|
}
|
|
|
|
// Create demo account
|
|
const account = await emailAccounts.create({
|
|
userId,
|
|
provider: 'demo',
|
|
email: demoEmail,
|
|
accessToken: 'demo-token',
|
|
refreshToken: '',
|
|
expiresAt: 0,
|
|
isActive: true,
|
|
lastSync: null,
|
|
})
|
|
|
|
log.success(`Demo account created for user ${userId}`)
|
|
|
|
respond.created(res, {
|
|
accountId: account.$id,
|
|
email: account.email,
|
|
provider: 'demo',
|
|
message: 'Demo account successfully created',
|
|
})
|
|
})
|
|
)
|
|
|
|
/**
|
|
* GET /api/email/accounts
|
|
* Get user's connected email accounts
|
|
*/
|
|
router.get('/accounts', asyncHandler(async (req, res) => {
|
|
const { userId } = req.query
|
|
|
|
if (!userId) {
|
|
throw new ValidationError('userId is required')
|
|
}
|
|
|
|
const accounts = await emailAccounts.getByUser(userId)
|
|
|
|
// Don't expose tokens
|
|
const safeAccounts = accounts.map(acc => ({
|
|
id: acc.$id,
|
|
email: acc.email,
|
|
provider: acc.provider,
|
|
connected: true,
|
|
lastSync: acc.lastSync,
|
|
isDemo: acc.provider === 'demo',
|
|
}))
|
|
|
|
respond.success(res, safeAccounts)
|
|
}))
|
|
|
|
/**
|
|
* DELETE /api/email/accounts/:accountId
|
|
* Disconnect an email account
|
|
*/
|
|
router.delete('/accounts/:accountId', asyncHandler(async (req, res) => {
|
|
const { accountId } = req.params
|
|
const { userId } = req.query
|
|
|
|
if (!userId) {
|
|
throw new ValidationError('userId is required')
|
|
}
|
|
|
|
// Verify ownership
|
|
const account = await emailAccounts.get(accountId)
|
|
|
|
if (account.userId !== userId) {
|
|
throw new AuthorizationError('No permission for this account')
|
|
}
|
|
|
|
await emailAccounts.deactivate(accountId)
|
|
|
|
log.info(`Email account disconnected: ${account.email}`)
|
|
|
|
respond.success(res, null, 'Account successfully disconnected')
|
|
}))
|
|
|
|
/**
|
|
* GET /api/email/stats
|
|
* Get email sorting statistics
|
|
*/
|
|
router.get('/stats', asyncHandler(async (req, res) => {
|
|
const { userId } = req.query
|
|
|
|
if (!userId) {
|
|
throw new ValidationError('userId is required')
|
|
}
|
|
|
|
const stats = await emailStats.getByUser(userId)
|
|
|
|
if (!stats) {
|
|
// Return empty stats for new users
|
|
return respond.success(res, {
|
|
totalSorted: 0,
|
|
todaySorted: 0,
|
|
weekSorted: 0,
|
|
categories: {},
|
|
timeSaved: 0,
|
|
})
|
|
}
|
|
|
|
respond.success(res, {
|
|
totalSorted: stats.totalSorted || 0,
|
|
todaySorted: stats.todaySorted || 0,
|
|
weekSorted: stats.weekSorted || 0,
|
|
categories: stats.categoriesJson ? JSON.parse(stats.categoriesJson) : {},
|
|
timeSaved: stats.timeSavedMinutes || 0,
|
|
})
|
|
}))
|
|
|
|
/**
|
|
* POST /api/email/sort
|
|
* Trigger email sorting for an account
|
|
*
|
|
* Options:
|
|
* - maxEmails: Maximum emails to process (default: 500, max: 2000)
|
|
* - processAll: If true, process entire inbox with pagination
|
|
*/
|
|
router.post('/sort',
|
|
limiters.emailSort,
|
|
validate({
|
|
body: {
|
|
userId: [rules.required('userId')],
|
|
accountId: [rules.required('accountId')],
|
|
},
|
|
}),
|
|
asyncHandler(async (req, res) => {
|
|
const { userId, accountId, maxEmails = 500, processAll = true } = req.body
|
|
const effectiveMax = Math.min(maxEmails, 2000) // Cap at 2000 emails
|
|
|
|
// Get account
|
|
const account = await emailAccounts.get(accountId)
|
|
|
|
if (account.userId !== userId) {
|
|
throw new AuthorizationError('No permission for this account')
|
|
}
|
|
|
|
// Get user preferences
|
|
const prefs = await userPreferences.getByUser(userId)
|
|
const preferences = prefs?.preferences || {}
|
|
|
|
// Get AI sorter
|
|
const sorter = await getAISorter()
|
|
let sortedCount = 0
|
|
const results = { byCategory: {} }
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// DEMO MODE - Sorting with simulated emails
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
if (account.provider === 'demo') {
|
|
log.info(`Demo sorting started for user ${userId}`)
|
|
|
|
// Select random emails from the demo pool
|
|
const emailCount = Math.min(maxEmails, DEMO_EMAILS.length)
|
|
const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5)
|
|
const emailsToSort = shuffled.slice(0, emailCount)
|
|
|
|
// Check if AI is available
|
|
if (features.ai()) {
|
|
// Real AI sorting with demo data
|
|
const classified = await sorter.batchCategorize(emailsToSort, preferences)
|
|
|
|
for (const { classification } of classified) {
|
|
const category = classification.category
|
|
sortedCount++
|
|
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
|
}
|
|
|
|
log.success(`AI sorting completed: ${sortedCount} demo emails`)
|
|
} else {
|
|
// Fallback without AI - simulated categorization
|
|
const categoryDistribution = {
|
|
vip: 0.1,
|
|
customers: 0.15,
|
|
invoices: 0.15,
|
|
newsletters: 0.2,
|
|
social: 0.15,
|
|
promotions: 0.1,
|
|
security: 0.05,
|
|
calendar: 0.05,
|
|
review: 0.05,
|
|
}
|
|
|
|
for (const email of emailsToSort) {
|
|
// Simple rule-based categorization
|
|
let category = 'review'
|
|
const from = email.from.toLowerCase()
|
|
const subject = email.subject.toLowerCase()
|
|
|
|
if (from.includes('boss') || from.includes('john.doe')) {
|
|
category = 'vip'
|
|
} else if (from.includes('billing') || subject.includes('invoice') || subject.includes('bill')) {
|
|
category = 'invoices'
|
|
} else if (from.includes('newsletter') || subject.includes('weekly') || subject.includes('news')) {
|
|
category = 'newsletters'
|
|
} else if (from.includes('linkedin') || from.includes('twitter') || from.includes('slack')) {
|
|
category = 'social'
|
|
} else if (from.includes('client') || subject.includes('proposal') || subject.includes('project')) {
|
|
category = 'customers'
|
|
} else if (from.includes('security') || subject.includes('security') || subject.includes('sign-in')) {
|
|
category = 'security'
|
|
} else if (subject.includes('off') || subject.includes('deal') || from.includes('marketing')) {
|
|
category = 'promotions'
|
|
} else if (subject.includes('invitation') || subject.includes('meeting') || subject.includes('calendar')) {
|
|
category = 'calendar'
|
|
}
|
|
|
|
sortedCount++
|
|
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
|
}
|
|
|
|
log.success(`Rule-based sorting completed: ${sortedCount} demo emails`)
|
|
}
|
|
}
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// GMAIL - Real Gmail sorting with native categories
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
else if (account.provider === 'gmail') {
|
|
if (!features.ai()) {
|
|
throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.')
|
|
}
|
|
|
|
if (!account.accessToken) {
|
|
throw new ValidationError('Gmail account needs to be reconnected')
|
|
}
|
|
|
|
log.info(`Gmail sorting started for ${account.email}`)
|
|
|
|
try {
|
|
const gmail = await getGmailService(account.accessToken, account.refreshToken)
|
|
|
|
// FIRST: Clean up old "EmailSorter/..." labels
|
|
const deletedLabels = await gmail.cleanupOldLabels()
|
|
if (deletedLabels > 0) {
|
|
log.success(`${deletedLabels} old labels cleaned up`)
|
|
}
|
|
|
|
// 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) {
|
|
labelMap[key] = gmailCat // z.B. CATEGORY_SOCIAL
|
|
} else {
|
|
// Create custom label (clean name without prefix)
|
|
try {
|
|
const label = await gmail.createLabel(cat.name, cat.color)
|
|
if (label) {
|
|
labelMap[key] = label.id
|
|
}
|
|
} catch (err) {
|
|
log.warn(`Failed to create label: ${cat.name}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
const batchSize = 100 // Gmail API batch size
|
|
|
|
do {
|
|
// Fetch batch of messages
|
|
const { messages, nextPageToken } = await gmail.listEmails(
|
|
batchSize,
|
|
pageToken,
|
|
'in:inbox' // All inbox emails
|
|
)
|
|
pageToken = nextPageToken
|
|
|
|
if (!messages?.length) break
|
|
|
|
// Get full email details
|
|
const emails = await gmail.batchGetEmails(messages.map(m => m.id))
|
|
|
|
// 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 (const { email, category, companyLabel } of processedEmails) {
|
|
const action = sorter.getCategoryAction(category, preferences)
|
|
|
|
try {
|
|
const labelsToAdd = []
|
|
const labelsToRemove = []
|
|
|
|
// Add company label if matched
|
|
if (companyLabel && companyLabelMap[companyLabel]) {
|
|
labelsToAdd.push(companyLabelMap[companyLabel])
|
|
}
|
|
|
|
// Add category label/category
|
|
if (labelMap[category]) {
|
|
labelsToAdd.push(labelMap[category])
|
|
}
|
|
|
|
// Handle different actions
|
|
switch (action) {
|
|
case 'star':
|
|
// Keep in inbox + add star
|
|
labelsToAdd.push('STARRED')
|
|
break
|
|
|
|
case 'archive_read':
|
|
// Archive + mark as read (clean inbox)
|
|
labelsToRemove.push('INBOX', 'UNREAD')
|
|
break
|
|
|
|
case 'archive':
|
|
// Just archive (legacy)
|
|
labelsToRemove.push('INBOX')
|
|
break
|
|
|
|
case 'inbox':
|
|
default:
|
|
// Keep in inbox, no changes needed
|
|
break
|
|
}
|
|
|
|
// Apply label changes
|
|
if (labelsToAdd.length > 0) {
|
|
await gmail.addLabels(email.id, labelsToAdd)
|
|
}
|
|
if (labelsToRemove.length > 0) {
|
|
await gmail.removeLabels(email.id, labelsToRemove)
|
|
}
|
|
|
|
sortedCount++
|
|
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
|
} catch (err) {
|
|
log.warn(`Email sorting failed: ${email.id}`, { error: err.message })
|
|
}
|
|
}
|
|
|
|
totalProcessed += emails.length
|
|
log.info(`Processed ${totalProcessed} emails so far...`)
|
|
|
|
// Stop if we've hit the limit or there's no more pages
|
|
if (totalProcessed >= effectiveMax) {
|
|
log.info(`Reached limit of ${effectiveMax} emails`)
|
|
break
|
|
}
|
|
|
|
// Small delay between batches to avoid rate limiting
|
|
if (pageToken) {
|
|
await new Promise(r => setTimeout(r, 200))
|
|
}
|
|
} while (pageToken && processAll)
|
|
|
|
log.success(`Gmail sorting completed: ${sortedCount} emails processed`)
|
|
} catch (err) {
|
|
log.error('Gmail sorting failed', { error: err.message })
|
|
throw new ValidationError(`Gmail error: ${err.message}. Please reconnect account.`)
|
|
}
|
|
}
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// OUTLOOK - Real Outlook sorting
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
else if (account.provider === 'outlook') {
|
|
if (!features.ai()) {
|
|
throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.')
|
|
}
|
|
|
|
if (!account.accessToken) {
|
|
throw new ValidationError('Outlook account needs to be reconnected')
|
|
}
|
|
|
|
log.info(`Outlook sorting started for ${account.email}`)
|
|
|
|
try {
|
|
const outlook = await getOutlookService(account.accessToken)
|
|
|
|
// Fetch and process ALL emails with pagination
|
|
let skipToken = null
|
|
let totalProcessed = 0
|
|
const batchSize = 100
|
|
|
|
do {
|
|
// Fetch batch of messages
|
|
const { messages, nextLink } = await outlook.listEmails(batchSize, skipToken)
|
|
skipToken = nextLink
|
|
|
|
if (!messages?.length) break
|
|
|
|
// 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 (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
|
|
switch (action) {
|
|
case 'archive_read':
|
|
// Archive + mark as read
|
|
await outlook.archiveEmail(email.id)
|
|
await outlook.markAsRead(email.id)
|
|
break
|
|
|
|
case 'archive':
|
|
await outlook.archiveEmail(email.id)
|
|
break
|
|
|
|
case 'star':
|
|
await outlook.flagEmail(email.id)
|
|
break
|
|
}
|
|
|
|
sortedCount++
|
|
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
|
} catch (err) {
|
|
log.warn(`Email sorting failed: ${email.id}`)
|
|
}
|
|
}
|
|
|
|
totalProcessed += messages.length
|
|
log.info(`Processed ${totalProcessed} emails so far...`)
|
|
|
|
// Stop if we've hit the limit
|
|
if (totalProcessed >= effectiveMax) {
|
|
log.info(`Reached limit of ${effectiveMax} emails`)
|
|
break
|
|
}
|
|
|
|
// Small delay between batches
|
|
if (skipToken) {
|
|
await new Promise(r => setTimeout(r, 200))
|
|
}
|
|
} while (skipToken && processAll)
|
|
|
|
log.success(`Outlook sorting completed: ${sortedCount} emails processed`)
|
|
} catch (err) {
|
|
log.error('Outlook sorting failed', { error: err.message })
|
|
throw new ValidationError(`Outlook error: ${err.message}. Please reconnect account.`)
|
|
}
|
|
}
|
|
|
|
// Update last sync
|
|
await emailAccounts.updateLastSync(accountId)
|
|
|
|
// Update stats
|
|
const timeSaved = Math.round(sortedCount * 0.25) // 15 seconds per email
|
|
await emailStats.increment(userId, {
|
|
total: sortedCount,
|
|
today: sortedCount,
|
|
week: sortedCount,
|
|
timeSaved,
|
|
})
|
|
|
|
// Update categories in stats
|
|
try {
|
|
const currentStats = await emailStats.getByUser(userId)
|
|
if (currentStats) {
|
|
const existingCategories = currentStats.categoriesJson
|
|
? JSON.parse(currentStats.categoriesJson)
|
|
: {}
|
|
|
|
for (const [cat, count] of Object.entries(results.byCategory)) {
|
|
existingCategories[cat] = (existingCategories[cat] || 0) + count
|
|
}
|
|
|
|
await emailStats.updateCategories(userId, existingCategories)
|
|
}
|
|
} catch (err) {
|
|
log.warn('Category update failed', { error: err.message })
|
|
}
|
|
|
|
// Calculate inbox cleared (archived emails)
|
|
const archivedCategories = ['newsletters', 'promotions', 'social']
|
|
const inboxCleared = archivedCategories.reduce((sum, cat) =>
|
|
sum + (results.byCategory[cat] || 0), 0
|
|
)
|
|
|
|
// Generate digest highlights (important emails)
|
|
const highlights = []
|
|
if (results.byCategory.vip > 0) {
|
|
highlights.push({ type: 'vip', count: results.byCategory.vip, message: `${results.byCategory.vip} important emails need attention` })
|
|
}
|
|
if (results.byCategory.security > 0) {
|
|
highlights.push({ type: 'security', count: results.byCategory.security, message: `${results.byCategory.security} security notifications` })
|
|
}
|
|
if (results.byCategory.invoices > 0) {
|
|
highlights.push({ type: 'invoices', count: results.byCategory.invoices, message: `${results.byCategory.invoices} invoices received` })
|
|
}
|
|
|
|
// Generate suggestions (potential unsubscribes)
|
|
const suggestions = []
|
|
if (results.byCategory.newsletters > 5) {
|
|
suggestions.push({ type: 'unsubscribe', message: 'Consider unsubscribing from some newsletters to reduce inbox clutter' })
|
|
}
|
|
if (results.byCategory.promotions > 10) {
|
|
suggestions.push({ type: 'unsubscribe', message: 'High volume of promotional emails - review subscriptions' })
|
|
}
|
|
|
|
// Update daily digest
|
|
try {
|
|
await emailDigests.updateToday(userId, {
|
|
stats: results.byCategory,
|
|
highlights,
|
|
suggestions,
|
|
totalSorted: sortedCount,
|
|
inboxCleared,
|
|
timeSavedMinutes: timeSaved,
|
|
})
|
|
} catch (err) {
|
|
log.warn('Digest update failed', { error: err.message })
|
|
}
|
|
|
|
log.success(`Sorting completed: ${sortedCount} emails for ${account.email}`)
|
|
|
|
respond.success(res, {
|
|
sorted: sortedCount,
|
|
inboxCleared,
|
|
categories: results.byCategory,
|
|
timeSaved: {
|
|
minutes: timeSaved,
|
|
formatted: timeSaved > 0 ? `${timeSaved} minutes` : '< 1 minute',
|
|
},
|
|
highlights,
|
|
suggestions,
|
|
provider: account.provider,
|
|
isDemo: account.provider === 'demo',
|
|
})
|
|
})
|
|
)
|
|
|
|
/**
|
|
* POST /api/email/sort-demo
|
|
* Quick demo sorting without account (for testing)
|
|
*/
|
|
router.post('/sort-demo', asyncHandler(async (req, res) => {
|
|
const { count = 10 } = req.body
|
|
|
|
log.info(`Quick demo sorting: ${count} emails`)
|
|
|
|
// Get AI sorter
|
|
const sorter = await getAISorter()
|
|
const results = { byCategory: {} }
|
|
|
|
// Select random emails
|
|
const emailCount = Math.min(count, DEMO_EMAILS.length)
|
|
const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5)
|
|
const emailsToSort = shuffled.slice(0, emailCount)
|
|
|
|
if (features.ai()) {
|
|
// Real AI sorting
|
|
const classified = await sorter.batchCategorize(emailsToSort, {})
|
|
|
|
const sortedEmails = emailsToSort.map((email, i) => ({
|
|
...email,
|
|
category: classified[i].classification.category,
|
|
categoryName: sorter.getLabelName(classified[i].classification.category),
|
|
confidence: classified[i].classification.confidence,
|
|
reason: classified[i].classification.reason,
|
|
}))
|
|
|
|
for (const email of sortedEmails) {
|
|
results.byCategory[email.category] = (results.byCategory[email.category] || 0) + 1
|
|
}
|
|
|
|
respond.success(res, {
|
|
sorted: emailCount,
|
|
emails: sortedEmails,
|
|
categories: results.byCategory,
|
|
aiEnabled: true,
|
|
})
|
|
} else {
|
|
// Rule-based sorting
|
|
const sortedEmails = emailsToSort.map(email => {
|
|
let category = 'review'
|
|
const from = email.from.toLowerCase()
|
|
const subject = email.subject.toLowerCase()
|
|
|
|
if (from.includes('boss') || from.includes('john.doe')) {
|
|
category = 'vip'
|
|
} else if (from.includes('billing') || subject.includes('invoice')) {
|
|
category = 'invoices'
|
|
} else if (from.includes('newsletter') || subject.includes('weekly')) {
|
|
category = 'newsletters'
|
|
} else if (from.includes('linkedin') || from.includes('twitter')) {
|
|
category = 'social'
|
|
} else if (from.includes('client') || subject.includes('proposal')) {
|
|
category = 'customers'
|
|
} else if (subject.includes('security') || subject.includes('sign-in')) {
|
|
category = 'security'
|
|
} else if (subject.includes('off') || from.includes('marketing')) {
|
|
category = 'promotions'
|
|
} else if (subject.includes('invitation') || subject.includes('meeting')) {
|
|
category = 'calendar'
|
|
}
|
|
|
|
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
|
|
|
return {
|
|
...email,
|
|
category,
|
|
categoryName: sorter.getLabelName(category),
|
|
}
|
|
})
|
|
|
|
respond.success(res, {
|
|
sorted: emailCount,
|
|
emails: sortedEmails,
|
|
categories: results.byCategory,
|
|
aiEnabled: false,
|
|
message: 'AI not configured - rule-based sorting used',
|
|
})
|
|
}
|
|
}))
|
|
|
|
/**
|
|
* POST /api/email/cleanup
|
|
* Cleanup old EmailSorter labels from Gmail
|
|
*/
|
|
router.post('/cleanup',
|
|
validate({
|
|
body: {
|
|
userId: [rules.required('userId')],
|
|
accountId: [rules.required('accountId')],
|
|
},
|
|
}),
|
|
asyncHandler(async (req, res) => {
|
|
const { userId, accountId } = req.body
|
|
|
|
const account = await emailAccounts.get(accountId)
|
|
|
|
if (account.userId !== userId) {
|
|
throw new AuthorizationError('No permission for this account')
|
|
}
|
|
|
|
if (account.provider !== 'gmail') {
|
|
return respond.success(res, { deleted: 0, message: 'Cleanup only available for Gmail' })
|
|
}
|
|
|
|
log.info(`Label cleanup started for ${account.email}`)
|
|
|
|
const gmail = await getGmailService(account.accessToken, account.refreshToken)
|
|
const deleted = await gmail.cleanupOldLabels()
|
|
|
|
log.success(`${deleted} old labels deleted`)
|
|
|
|
respond.success(res, {
|
|
deleted,
|
|
message: deleted > 0
|
|
? `${deleted} old "EmailSorter/..." labels were deleted`
|
|
: 'No old labels found'
|
|
})
|
|
})
|
|
)
|
|
|
|
/**
|
|
* GET /api/email/digest
|
|
* Get today's sorting digest summary
|
|
*/
|
|
router.get('/digest', asyncHandler(async (req, res) => {
|
|
const { userId } = req.query
|
|
|
|
if (!userId) {
|
|
throw new ValidationError('userId is required')
|
|
}
|
|
|
|
const digest = await emailDigests.getByUserToday(userId)
|
|
|
|
if (!digest) {
|
|
// Return empty digest for new users
|
|
return respond.success(res, {
|
|
date: new Date().toISOString().split('T')[0],
|
|
totalSorted: 0,
|
|
inboxCleared: 0,
|
|
timeSavedMinutes: 0,
|
|
stats: {},
|
|
highlights: [],
|
|
suggestions: [],
|
|
hasData: false,
|
|
})
|
|
}
|
|
|
|
respond.success(res, {
|
|
date: digest.date,
|
|
totalSorted: digest.totalSorted,
|
|
inboxCleared: digest.inboxCleared,
|
|
timeSavedMinutes: digest.timeSavedMinutes,
|
|
stats: digest.stats,
|
|
highlights: digest.highlights,
|
|
suggestions: digest.suggestions,
|
|
hasData: true,
|
|
})
|
|
}))
|
|
|
|
/**
|
|
* GET /api/email/digest/history
|
|
* Get digest history for the last N days
|
|
*/
|
|
router.get('/digest/history', asyncHandler(async (req, res) => {
|
|
const { userId, days = 7 } = req.query
|
|
|
|
if (!userId) {
|
|
throw new ValidationError('userId is required')
|
|
}
|
|
|
|
const digests = await emailDigests.getByUserRecent(userId, parseInt(days))
|
|
|
|
// Calculate totals
|
|
const totals = {
|
|
totalSorted: digests.reduce((sum, d) => sum + (d.totalSorted || 0), 0),
|
|
inboxCleared: digests.reduce((sum, d) => sum + (d.inboxCleared || 0), 0),
|
|
timeSavedMinutes: digests.reduce((sum, d) => sum + (d.timeSavedMinutes || 0), 0),
|
|
}
|
|
|
|
respond.success(res, {
|
|
days: parseInt(days),
|
|
digests: digests.map(d => ({
|
|
date: d.date,
|
|
totalSorted: d.totalSorted,
|
|
inboxCleared: d.inboxCleared,
|
|
timeSavedMinutes: d.timeSavedMinutes,
|
|
stats: d.stats,
|
|
})),
|
|
totals,
|
|
})
|
|
}))
|
|
|
|
/**
|
|
* GET /api/email/categories
|
|
* Get all available categories
|
|
*/
|
|
router.get('/categories', asyncHandler(async (req, res) => {
|
|
const sorter = await getAISorter()
|
|
const categories = sorter.getCategories()
|
|
|
|
const formattedCategories = Object.entries(categories).map(([key, cat]) => ({
|
|
id: key,
|
|
name: cat.name,
|
|
description: cat.description,
|
|
color: cat.color,
|
|
action: cat.action,
|
|
priority: cat.priority,
|
|
}))
|
|
|
|
respond.success(res, formattedCategories)
|
|
}))
|
|
|
|
/**
|
|
* POST /api/email/webhook/gmail
|
|
* Gmail push notification webhook
|
|
*/
|
|
router.post('/webhook/gmail', asyncHandler(async (req, res) => {
|
|
// Acknowledge immediately
|
|
res.status(200).send()
|
|
|
|
const { message } = req.body
|
|
if (!message?.data) return
|
|
|
|
try {
|
|
const data = JSON.parse(Buffer.from(message.data, 'base64').toString())
|
|
log.info(`Gmail Webhook: ${data.emailAddress}`, { historyId: data.historyId })
|
|
// Queue processing in production
|
|
} catch (err) {
|
|
log.error('Gmail Webhook Error', { error: err.message })
|
|
}
|
|
}))
|
|
|
|
/**
|
|
* POST /api/email/webhook/outlook
|
|
* Microsoft Graph webhook
|
|
*/
|
|
router.post('/webhook/outlook', asyncHandler(async (req, res) => {
|
|
// Handle validation request
|
|
if (req.query.validationToken) {
|
|
return res.status(200).send(req.query.validationToken)
|
|
}
|
|
|
|
res.status(202).send()
|
|
|
|
const { value } = req.body
|
|
if (!value?.length) return
|
|
|
|
for (const notification of value) {
|
|
log.info(`Outlook Webhook: ${notification.changeType}`, { resource: notification.resource })
|
|
// Queue processing in production
|
|
}
|
|
}))
|
|
|
|
export default router
|