Files
Emailsorter/server/routes/email.mjs
ANDJ 6da8ce1cbd huhuih
hzgjuigik
2026-01-27 21:06:48 +01:00

1396 lines
48 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, emailUsage, subscriptions } 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
// Check subscription status and free tier limits
const subscription = await subscriptions.getByUser(userId)
const isFreeTier = subscription?.isFreeTier || false
// Check free tier limit
if (isFreeTier) {
const usage = await emailUsage.getUsage(userId)
const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth
if (usage.emailsProcessed >= limit) {
return respond.error(res, {
code: 'LIMIT_REACHED',
message: `You've processed ${limit} emails this month. Upgrade for unlimited sorting.`,
limit,
used: usage.emailsProcessed,
}, 403)
}
}
// Check if this is first run (no stats exist)
const existingStats = await emailStats.getByUser(userId)
const isFirstRun = !existingStats || existingStats.totalSorted === 0
// For first run, limit to 50 emails for speed
const effectiveMax = isFirstRun
? Math.min(maxEmails, 50)
: 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: {} }
let emailSamples = [] // For suggested rules generation
// ═══════════════════════════════════════════════════════════════════════
// 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 { email, classification } of classified) {
const category = classification.category
sortedCount++
results.byCategory[category] = (results.byCategory[category] || 0) + 1
// Collect samples for suggested rules (first run only, max 50)
if (isFirstRun && emailSamples.length < 50) {
emailSamples.push({
from: email.from,
subject: email.subject,
snippet: email.snippet,
category,
})
}
}
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
// Collect samples for suggested rules (first run only, max 50)
if (isFirstRun && emailSamples.length < 50) {
emailSamples.push({
from: email.from,
subject: email.subject,
snippet: email.snippet || '',
category,
})
}
}
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,
})
// Collect samples for suggested rules (first run only, max 50)
if (isFirstRun && emailSamples.length < 50) {
emailSamples.push({
from: emailData.from,
subject: emailData.subject,
snippet: emailData.snippet,
category,
})
}
}
// 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 email usage (for free tier tracking)
if (isFreeTier) {
await emailUsage.increment(userId, sortedCount)
}
// Update stats
const timeSaved = Math.round(sortedCount * 0.1) // 6 seconds per email = 0.1 minutes
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}`)
// Generate suggested rules for first run
let suggestedRules = []
if (isFirstRun && emailSamples.length > 0 && features.ai()) {
try {
suggestedRules = await sorter.generateSuggestedRules(userId, emailSamples)
log.info(`Generated ${suggestedRules.length} suggested rules for first run`)
} catch (err) {
log.warn('Failed to generate suggested rules', { error: err.message })
}
}
respond.success(res, {
sorted: sortedCount,
inboxCleared,
categories: results.byCategory,
timeSaved: {
minutes: timeSaved,
formatted: timeSaved > 0 ? `${timeSaved} minutes` : '< 1 minute',
},
isFirstRun,
suggestedRules: suggestedRules.length > 0 ? suggestedRules : undefined,
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
}
}))
/**
* POST /api/email/cleanup
* Run auto-cleanup for read items and promotions
* Can be called manually or by cron job
*/
router.post('/cleanup', asyncHandler(async (req, res) => {
const { userId } = req.body // Optional: only process this user, otherwise all users
log.info('Cleanup job started', { userId: userId || 'all' })
const results = {
usersProcessed: 0,
emailsProcessed: {
readItems: 0,
promotions: 0,
},
errors: [],
}
try {
// Get all users with cleanup enabled
let usersToProcess = []
if (userId) {
// Single user mode
const prefs = await userPreferences.getByUser(userId)
if (prefs?.preferences?.cleanup?.enabled) {
usersToProcess = [{ userId, preferences: prefs.preferences }]
}
} else {
// All users mode - get all user preferences
// Note: This is a simplified approach. In production, you might want to add an index
// or query optimization for users with cleanup.enabled = true
const allPrefs = await emailAccounts.getByUser('*') // This won't work, need different approach
// For now, we'll process users individually when they have accounts
// TODO: Add efficient query for users with cleanup enabled
log.warn('Processing all users not yet implemented efficiently. Use userId parameter for single user cleanup.')
}
// If userId provided, process that user
if (userId) {
const prefs = await userPreferences.getByUser(userId)
if (!prefs?.preferences?.cleanup?.enabled) {
return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
}
const accounts = await emailAccounts.getByUser(userId)
if (!accounts || accounts.length === 0) {
return respond.success(res, { ...results, message: 'No email accounts found' })
}
for (const account of accounts) {
if (!account.isActive || !account.accessToken) continue
try {
const cleanup = prefs.preferences.cleanup
// Read Items Cleanup
if (cleanup.readItems?.enabled) {
const readItemsCount = await processReadItemsCleanup(
account,
cleanup.readItems.action,
cleanup.readItems.gracePeriodDays
)
results.emailsProcessed.readItems += readItemsCount
}
// Promotion Cleanup
if (cleanup.promotions?.enabled) {
const promotionsCount = await processPromotionsCleanup(
account,
cleanup.promotions.action,
cleanup.promotions.deleteAfterDays,
cleanup.promotions.matchCategoriesOrLabels || []
)
results.emailsProcessed.promotions += promotionsCount
}
results.usersProcessed = 1
} catch (error) {
log.error(`Cleanup failed for account ${account.email}`, { error: error.message })
results.errors.push({ userId, accountId: account.id, error: error.message })
}
}
}
log.success('Cleanup job completed', results)
respond.success(res, results, 'Cleanup completed')
} catch (error) {
log.error('Cleanup job failed', { error: error.message })
throw error
}
}))
/**
* Process read items cleanup for an account
*/
async function processReadItemsCleanup(account, action, gracePeriodDays) {
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays)
let processedCount = 0
try {
if (account.provider === 'gmail') {
const gmail = await getGmailService(account.accessToken, account.refreshToken)
// Find read emails older than grace period
// Query: -is:unread AND before:YYYY/MM/DD
const query = `-is:unread before:${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
const response = await gmail.gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: 500, // Limit to avoid rate limits
})
const messages = response.data.messages || []
for (const message of messages) {
try {
if (action === 'archive_read') {
await gmail.archiveEmail(message.id)
await gmail.markAsRead(message.id)
} else if (action === 'trash') {
await gmail.trashEmail(message.id)
}
processedCount++
} catch (err) {
log.error(`Failed to process message ${message.id}`, { error: err.message })
}
}
} else if (account.provider === 'outlook') {
const outlook = await getOutlookService(account.accessToken)
// Find read emails older than grace period
const filter = `isRead eq true and receivedDateTime lt ${cutoffDate.toISOString()}`
const messages = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=500`)
for (const message of messages.value || []) {
try {
if (action === 'archive_read') {
await outlook.archiveEmail(message.id)
await outlook.markAsRead(message.id)
} else if (action === 'trash') {
await outlook.deleteEmail(message.id) // Outlook uses deleteEmail for trash
}
processedCount++
} catch (err) {
log.error(`Failed to process message ${message.id}`, { error: err.message })
}
}
}
} catch (error) {
log.error(`Read items cleanup failed for ${account.email}`, { error: error.message })
throw error
}
return processedCount
}
/**
* Process promotions cleanup for an account
*/
async function processPromotionsCleanup(account, action, deleteAfterDays, matchCategories) {
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - deleteAfterDays)
let processedCount = 0
try {
if (account.provider === 'gmail') {
const gmail = await getGmailService(account.accessToken, account.refreshToken)
// Find emails with matching categories/labels older than deleteAfterDays
// Look for emails with EmailSorter labels matching the categories
const labelQueries = matchCategories.map(cat => `label:EmailSorter/${cat}`).join(' OR ')
const query = `(${labelQueries}) before:${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
const response = await gmail.gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: 500,
})
const messages = response.data.messages || []
for (const message of messages) {
try {
if (action === 'archive_read') {
await gmail.archiveEmail(message.id)
await gmail.markAsRead(message.id)
} else if (action === 'trash') {
await gmail.trashEmail(message.id)
}
processedCount++
} catch (err) {
log.error(`Failed to process promotion message ${message.id}`, { error: err.message })
}
}
} else if (account.provider === 'outlook') {
const outlook = await getOutlookService(account.accessToken)
// For Outlook, we'd need to check categories or use a different approach
// This is a simplified version - in production, you might store category info
const filter = `receivedDateTime lt ${cutoffDate.toISOString()}`
const messages = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=500`)
// Filter by category if available (would need to be stored during sorting)
for (const message of messages.value || []) {
// TODO: Check if message category matches matchCategories
// This requires storing category info during sorting
try {
if (action === 'archive_read') {
await outlook.archiveEmail(message.id)
await outlook.markAsRead(message.id)
} else if (action === 'trash') {
await outlook.deleteEmail(message.id) // Outlook uses deleteEmail for trash
}
processedCount++
} catch (err) {
log.error(`Failed to process promotion message ${message.id}`, { error: err.message })
}
}
}
} catch (error) {
log.error(`Promotions cleanup failed for ${account.email}`, { error: error.message })
throw error
}
return processedCount
}
export default router