Email Sorter Beta

Ich habe soweit automatisiert the Emails sortieren aber ich muss noch schauen was es fur bugs es gibt wenn die app online  ist deswegen wurde ich mit diesen Commit die website veroffentlichen obwohjl es sein konnte  das es noch nicht fertig ist und verkaufs bereit
This commit is contained in:
2026-01-22 19:32:12 +01:00
parent 95349af50b
commit abf761db07
596 changed files with 56405 additions and 51231 deletions

948
server/routes/email.mjs Normal file
View File

@@ -0,0 +1,948 @@
/**
* 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 only for categories without native Gmail category
const categories = sorter.getCategories()
const labelMap = {}
for (const [key, cat] of Object.entries(categories)) {
// 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}`)
}
}
}
// 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))
// Classify emails with AI
const classified = await sorter.batchCategorize(
emails.map(e => ({
from: e.headers?.from || '',
subject: e.headers?.subject || '',
snippet: e.snippet || '',
})),
preferences
)
// Apply labels/categories and actions
for (let i = 0; i < classified.length; i++) {
const email = emails[i]
const { category } = classified[i].classification
const action = sorter.getCategoryAction(category)
try {
const labelsToAdd = []
const labelsToRemove = []
// Add 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
// Classify emails with AI
const classified = await sorter.batchCategorize(
messages.map(e => ({
from: e.from?.emailAddress?.address || '',
subject: e.subject || '',
snippet: e.bodyPreview || '',
})),
preferences
)
// Apply categories and actions
for (let i = 0; i < classified.length; i++) {
const email = messages[i]
const { category } = classified[i].classification
const action = sorter.getCategoryAction(category)
const catName = sorter.getLabelName(category)
try {
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