hzgjuigik
This commit is contained in:
2026-01-27 21:06:48 +01:00
parent 18c11d27bc
commit 6da8ce1cbd
51 changed files with 6208 additions and 974 deletions

View File

@@ -8,7 +8,7 @@ import { asyncHandler, NotFoundError, AuthorizationError, ValidationError } from
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 { emailAccounts, emailStats, emailDigests, userPreferences, emailUsage, subscriptions } from '../services/database.mjs'
import { config, features } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs'
@@ -270,7 +270,34 @@ router.post('/sort',
}),
asyncHandler(async (req, res) => {
const { userId, accountId, maxEmails = 500, processAll = true } = req.body
const effectiveMax = Math.min(maxEmails, 2000) // Cap at 2000 emails
// 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)
@@ -287,6 +314,7 @@ router.post('/sort',
const sorter = await getAISorter()
let sortedCount = 0
const results = { byCategory: {} }
let emailSamples = [] // For suggested rules generation
// ═══════════════════════════════════════════════════════════════════════
// DEMO MODE - Sorting with simulated emails
@@ -304,10 +332,20 @@ router.post('/sort',
// Real AI sorting with demo data
const classified = await sorter.batchCategorize(emailsToSort, preferences)
for (const { classification } of classified) {
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`)
@@ -351,6 +389,16 @@ router.post('/sort',
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`)
@@ -512,6 +560,16 @@ router.post('/sort',
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
@@ -740,8 +798,13 @@ router.post('/sort',
// 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.25) // 15 seconds per email
const timeSaved = Math.round(sortedCount * 0.1) // 6 seconds per email = 0.1 minutes
await emailStats.increment(userId, {
total: sortedCount,
today: sortedCount,
@@ -810,6 +873,17 @@ router.post('/sort',
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,
@@ -818,6 +892,8 @@ router.post('/sort',
minutes: timeSaved,
formatted: timeSaved > 0 ? `${timeSaved} minutes` : '< 1 minute',
},
isFirstRun,
suggestedRules: suggestedRules.length > 0 ? suggestedRules : undefined,
highlights,
suggestions,
provider: account.provider,
@@ -1083,4 +1159,237 @@ router.post('/webhook/outlook', asyncHandler(async (req, res) => {
}
}))
/**
* 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