/** * 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, isAdmin } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' import { requireAuthUnlessEmailWebhook } from '../middleware/auth.mjs' import { encryptImapSecret, decryptImapSecret } from '../utils/crypto.mjs' import { isAppwriteCollectionMissing } from '../utils/appwriteErrors.mjs' import { AI_BATCH_CHUNK_SIZE, AI_BATCH_CHUNK_DELAY_MS } from '../services/ai-sorter.mjs' import { CATEGORY_FOLDER_KEYWORDS, findBestFolder, findPersonFolder } from '../services/imap.mjs' const router = express.Router() router.use(requireAuthUnlessEmailWebhook) // 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 } /** Reject after `ms` so the IMAP sort handler cannot hang indefinitely. */ function imapSortRaceWithTimeout(promise, ms, message) { let timer const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new ValidationError(message)), ms) }) return Promise.race([ promise.finally(() => clearTimeout(timer)), timeout, ]) } /** * IMAP credentials: new accounts store JSON in accessToken; legacy uses encrypted password only. */ export function parseImapAccountAccess(account) { const fallbackHost = account.imapHost || 'imap.porkbun.com' const fallbackPort = account.imapPort != null ? Number(account.imapPort) : 993 const fallbackSecure = account.imapSecure !== false if (!account.accessToken) { return { host: fallbackHost, port: fallbackPort, secure: fallbackSecure, password: '' } } try { const parsed = JSON.parse(account.accessToken) if (parsed && typeof parsed === 'object' && typeof parsed.password === 'string') { return { host: parsed.host ?? fallbackHost, port: parsed.port != null ? Number(parsed.port) : fallbackPort, secure: parsed.secure !== false, password: decryptImapSecret(parsed.password), } } } catch { // legacy: entire accessToken is encrypted secret } return { host: fallbackHost, port: fallbackPort, secure: fallbackSecure, password: decryptImapSecret(account.accessToken), } } // ═══════════════════════════════════════════════════════════════════════════ // 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: { provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])], email: [rules.required('email'), rules.email()], }, }), asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body // IMAP: require password (or accessToken as password) if (provider === 'imap') { const imapPassword = password || accessToken if (!imapPassword) { throw new ValidationError('IMAP account requires a password or app password', { password: ['Required for IMAP'] }) } } // 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'], }) } // IMAP: verify connection before saving if (provider === 'imap') { const { ImapService } = await import('../services/imap.mjs') const imapPassword = password || accessToken const imap = new ImapService({ host: imapHost || 'imap.porkbun.com', port: imapPort != null ? Number(imapPort) : 993, secure: imapSecure !== false, user: email, password: imapPassword, }) try { await imap.connect() await imap.listEmails(1) await imap.close() } catch (err) { log.warn('IMAP connection test failed', { email, error: err.message }) throw new ValidationError('IMAP connection failed. Check email and password (use app password if 2FA is on).', { password: [err.message || 'Connection failed'] }) } } // Create account (IMAP: encode host/port/secure inside accessToken JSON — no Appwrite attrs) const rawImapSecret = provider === 'imap' ? (password || accessToken) : '' const accountData = { userId, provider, email, accessToken: provider === 'imap' ? JSON.stringify({ password: encryptImapSecret(rawImapSecret), host: imapHost || 'imap.porkbun.com', port: imapPort != null ? Number(imapPort) : 993, secure: imapSecure !== false, }) : (accessToken || ''), refreshToken: provider === 'imap' ? '' : (refreshToken || ''), expiresAt: provider === 'imap' ? 0 : (expiresAt || 0), isActive: true, lastSync: null, } const account = await emailAccounts.create(accountData) 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', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const demoEmail = `demo-${userId.slice(0, 8)}@mailflow.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.appwriteUser.id 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.appwriteUser.id // 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.appwriteUser.id 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; IMAP default/cap 50, max 2000 non-IMAP) * - processAll: If true, process entire inbox with pagination */ router.post('/sort', limiters.emailSort, validate({ body: { accountId: [rules.required('accountId')], }, }), asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { accountId, processAll = true } = req.body console.log('[SORT] Step 1: Auth OK, userId:', userId) const subscriptionFreeTierDefaults = () => ({ plan: 'free', status: 'active', isFreeTier: true, emailsUsedThisMonth: 0, emailsLimit: config.freeTier.emailsPerMonth, }) // Check subscription status and free tier limits (missing collections → treat as free tier) let subscription try { subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email) } catch { subscription = null } if (!subscription) { subscription = subscriptionFreeTierDefaults() } const isFreeTier = subscription?.isFreeTier || false const adminUser = isAdmin(req.appwriteUser?.email) console.log('[SORT] Step 3: Subscription:', subscription?.plan, 'isFreeTier:', subscription?.isFreeTier) // Check free tier limit (admins: unlimited) if (isFreeTier && !adminUser) { 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) } } const existingStats = await emailStats.getByUser(userId) const isFirstRun = !existingStats || existingStats.totalSorted === 0 const account = await emailAccounts.get(accountId) if (!account) { throw new NotFoundError('Email account') } if (account.userId !== userId) { throw new AuthorizationError('No permission for this account') } let maxEmails = req.body.maxEmails if (maxEmails == null) { maxEmails = 500 } else { maxEmails = Number(maxEmails) if (!Number.isFinite(maxEmails) || maxEmails < 0) { maxEmails = 500 } } if (account.provider === 'imap') { maxEmails = Math.min(maxEmails, 500) } const effectiveMax = account.provider === 'imap' ? Math.min(maxEmails, 500) : isFirstRun ? Math.min(maxEmails, 50) : Math.min(maxEmails, 2000) console.log('[SORT] Step 2: Account fetched:', account?.$id ?? 'NULL', 'provider:', account?.provider) // Get user preferences const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || {} // Get AI sorter const sorter = await getAISorter() let sortedCount = 0 let timedOut = false 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) console.log('[SORT] Step 4: Emails fetched (demo):', emailsToSort?.length ?? 0) // Check if AI is available if (features.ai()) { // Real AI sorting with demo data const classified = await sorter.batchCategorize(emailsToSort, preferences) console.log('[SORT] Step 5: Categorized (demo AI):', classified?.length ?? 0) 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`) } console.log('[SORT] Step 5: Categorized (demo final sortedCount):', sortedCount) } // ═══════════════════════════════════════════════════════════════════════ // 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 "MailFlow/..." 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 } } } // Create name labels (workers) – personal labels per team member const nameLabelMap = {} if (preferences.nameLabels?.length) { for (const nl of preferences.nameLabels) { if (!nl.enabled) continue try { const labelName = `MailFlow/Team/${nl.name}` const label = await gmail.createLabel(labelName, '#4a86e8') if (label) { nameLabelMap[nl.id || nl.name] = label.id if (nl.name !== (nl.id || nl.name)) nameLabelMap[nl.name] = label.id } } catch (err) { log.warn(`Failed to create name label: ${nl.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)) console.log('[SORT] Step 4: Emails fetched (gmail batch):', emails?.length ?? 0) // 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 assignedTo = 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 assignedTo = classification.assignedTo || null // If category is disabled, fallback to review if (!enabledCategories.includes(category)) { category = 'review' } } processedEmails.push({ email, category, companyLabel, assignedTo, }) // 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, assignedTo } 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 name label (worker) if AI assigned email to a person if (assignedTo && nameLabelMap[assignedTo]) { labelsToAdd.push(nameLabelMap[assignedTo]) } // 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) console.log('[SORT] Step 5: Categorized (gmail sortedCount):', sortedCount) 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 console.log('[SORT] Step 4: Emails fetched (outlook batch):', messages?.length ?? 0) // 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, }) } console.log('[SORT] Step 5: Categorized (outlook batch processed):', processedEmails.length) // 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) console.log('[SORT] Step 5: Categorized (outlook sortedCount):', sortedCount) 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.`) } } // ═══════════════════════════════════════════════════════════════════════ // IMAP (Porkbun, Nextcloud mail backend, etc.) // ═══════════════════════════════════════════════════════════════════════ else if (account.provider === 'imap') { if (!features.ai()) { throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.') } if (!account.accessToken) { throw new ValidationError('IMAP account needs to be reconnected (password missing)') } log.info(`IMAP sorting started for ${account.email}`) const { ImapService } = await import('../services/imap.mjs') const imapCfg = parseImapAccountAccess(account) const imap = new ImapService({ host: imapCfg.host, port: imapCfg.port, secure: imapCfg.secure, user: account.email, password: imapCfg.password, }) try { try { await imapSortRaceWithTimeout( (async () => { try { await imap.connect() const enabledCategories = sorter.getEnabledCategories(preferences) const existingFolders = await imap.listAllFolders() console.log('[SORT-IMAP] All available folders:', existingFolders) const folderPathSet = new Set(existingFolders) console.log(`[SORT-IMAP] Folders discovered: ${existingFolders.length}`) const folderMap = {} for (const cat of Object.keys(CATEGORY_FOLDER_KEYWORDS)) { folderMap[cat] = findBestFolder(cat, existingFolders) } console.log('[SORT-IMAP] Folder map:', JSON.stringify(folderMap)) const fetchCap = Math.min(500, effectiveMax) const { messages } = await imap.listEmails(fetchCap, null) if (!messages?.length) { console.log('[SORT-IMAP] No messages in INBOX to process') log.success('IMAP sorting completed: 0 emails processed') return } const emails = await imap.batchGetEmails(messages.map((m) => m.id)) console.log('[SORT-IMAP] Emails fetched (batch):', emails?.length ?? 0) const processedEmails = [] for (let chunkStart = 0; chunkStart < emails.length; chunkStart += AI_BATCH_CHUNK_SIZE) { const chunk = emails.slice(chunkStart, chunkStart + AI_BATCH_CHUNK_SIZE) for (const email of chunk) { const emailData = { from: email.headers?.from || '', subject: email.headers?.subject || '', snippet: email.snippet || '', } let category = null let companyLabel = null let assignedTo = null let skipAI = false 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 } } } if (!skipAI && preferences.autoDetectCompanies) { const detected = sorter.detectCompany(emailData) if (detected) { category = 'promotions' companyLabel = detected.label skipAI = true } } if (!skipAI) { const classification = await sorter.categorize(emailData, preferences) category = classification.category assignedTo = classification.assignedTo || null if (!enabledCategories.includes(category)) category = 'review' } processedEmails.push({ email, category, companyLabel, assignedTo }) if (isFirstRun && emailSamples.length < 50) { emailSamples.push({ from: emailData.from, subject: emailData.subject, snippet: emailData.snippet, category, }) } } if (chunkStart + AI_BATCH_CHUNK_SIZE < emails.length) { await new Promise((r) => setTimeout(r, AI_BATCH_CHUNK_DELAY_MS)) } } console.log('[SORT-IMAP] Categorized:', processedEmails.length) const MOVE_OUT_COMPLETELY = ['newsletters', 'promotions', 'social'] let movedCount = 0 let copiedCount = 0 let sortDecisionLogCount = 0 for (const { email, category, companyLabel, assignedTo } of processedEmails) { const resolvedCategory = companyLabel ? preferences.companyLabels?.find((c) => c.name === companyLabel)?.category || 'promotions' : category const emailDataForPerson = { from: email.headers?.from || '', subject: email.headers?.subject || '', } const personFolder = findPersonFolder(emailDataForPerson, existingFolders) const categoryFolder = folderMap[resolvedCategory] || null const action = sorter.getCategoryAction ? sorter.getCategoryAction(resolvedCategory, preferences) : 'inbox' const didEarlyArchiveRead = MOVE_OUT_COMPLETELY.includes(resolvedCategory) && categoryFolder && action === 'archive_read' if (didEarlyArchiveRead) { try { await imap.markAsRead(email.id) } catch { // ignore — message may already be moved in edge cases } } if (personFolder) { try { const copied = await imap.copyToFolder(email.id, personFolder, folderPathSet) if (copied) { copiedCount++ console.log( `[SORT-IMAP] Email ${email.id} → copied to person folder "${personFolder}"` ) } } catch { // ignore } } if (categoryFolder && categoryFolder !== personFolder) { if (MOVE_OUT_COMPLETELY.includes(resolvedCategory)) { try { const moved = await imap.moveMessageToExistingPath( email.id, categoryFolder, folderPathSet ) if (moved) { movedCount++ console.log(`[SORT-IMAP] Email ${email.id} → MOVED to "${categoryFolder}"`) } else { const copied = await imap.copyToFolder( email.id, categoryFolder, folderPathSet ) if (copied) { copiedCount++ console.log( `[SORT-IMAP] Email ${email.id} → copied (move failed) to "${categoryFolder}"` ) } } } catch { // ignore } } else { try { const copied = await imap.copyToFolder( email.id, categoryFolder, folderPathSet ) if (copied) { copiedCount++ console.log( `[SORT-IMAP] Email ${email.id} → copied to "${categoryFolder}"` ) } } catch { // ignore } } } if (!MOVE_OUT_COMPLETELY.includes(resolvedCategory) || personFolder) { try { await imap.addMailFlowCategoryKeyword( email.id, resolvedCategory, assignedTo || null ) } catch { // ignore } } try { if (action === 'archive_read' && !didEarlyArchiveRead) { await imap.markAsRead(email.id) } } catch { // ignore — e.g. message already moved out of INBOX } sortedCount++ results.byCategory[resolvedCategory] = (results.byCategory[resolvedCategory] || 0) + 1 if (sortDecisionLogCount < 20) { sortDecisionLogCount++ console.log( `[SORT-IMAP] Decision ${sortDecisionLogCount}/20: uid=${email.id} category=${resolvedCategory} ` + `personFolder=${personFolder || '—'} categoryFolder=${categoryFolder || '—'} ` + `moveOut=${MOVE_OUT_COMPLETELY.includes(resolvedCategory)}` ) } } console.log( `[SORT-IMAP] Complete: ${movedCount} moved, ${copiedCount} copied` ) log.success(`IMAP sorting completed: ${sortedCount} emails processed`) } catch (err) { log.error('IMAP sorting failed', { error: err.message }) throw new ValidationError(`IMAP error: ${err.message}. Check credentials or reconnect.`) } })(), 300_000, 'IMAP sort timed out after 300 seconds. Check host, port, or network.' ) } catch (err) { if (String(err?.message || '').includes('timed out')) { timedOut = true log.warn( `[SORT-IMAP] Timed out after 300s — saving partial results (${sortedCount} sorted so far)` ) } else { throw err } } } finally { try { await imap.close() } catch { // ignore — dead connection must not block HTTP response } } } console.log('[SORT] Step 6: Saving results (sync, stats, usage, digest)...') // Update last sync await emailAccounts.updateLastSync(accountId) // Update email usage (for free tier tracking; admins are "business", skip counter) if (isFreeTier && !adminUser) { 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 }) } console.log('[SORT] Step 6: Results saved, sortedCount:', sortedCount) 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', timedOut: timedOut || undefined, message: timedOut ? `Sorted ${sortedCount} emails (sort timed out, will continue next run)` : undefined, }) }) ) /** * POST /api/email/recover/:accountId * Move messages from all non-INBOX folders back to INBOX (IMAP only). */ router.post( '/recover/:accountId', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { accountId } = req.params const account = await emailAccounts.get(accountId) if (!account) throw new NotFoundError('Email account') if (account.userId !== userId) throw new AuthorizationError('No permission for this account') if (account.provider !== 'imap') { return respond.success(res, { recovered: 0, folders: [], message: 'Only available for IMAP accounts', }) } const { ImapService } = await import('../services/imap.mjs') const imapCfg = parseImapAccountAccess(account) const imap = new ImapService({ host: imapCfg.host, port: imapCfg.port, secure: imapCfg.secure, user: account.email, password: imapCfg.password, }) log.info(`Email recovery started for ${account.email}`) const result = await imap.recoverAllToInbox() log.success(`Recovery complete: ${result.recovered} emails returned to INBOX`) respond.success(res, { recovered: result.recovered, folders: result.folders, message: result.recovered > 0 ? `${result.recovered} emails recovered back to inbox` : 'No emails found outside inbox', }) }) ) /** * POST /api/email/re-sort/:accountId * IMAP: move messages from sort-related folders (Junk, Archive, MailFlow/*, …) back to INBOX and strip $MailFlow-* keywords. */ router.post( '/re-sort/:accountId', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { accountId } = req.params const account = await emailAccounts.get(accountId) if (!account) throw new NotFoundError('Email account') if (account.userId !== userId) throw new AuthorizationError('No permission for this account') if (account.provider !== 'imap') { return respond.success(res, { recovered: 0, folders: [], mailFlowKeywordsStripped: 0, message: 'Only available for IMAP accounts', }) } const { ImapService } = await import('../services/imap.mjs') const imapCfg = parseImapAccountAccess(account) const imap = new ImapService({ host: imapCfg.host, port: imapCfg.port, secure: imapCfg.secure, user: account.email, password: imapCfg.password, }) log.info(`IMAP re-sort prep started for ${account.email}`) try { await imap.connect() const result = await imap.reSortRecoverAndStripKeywords() log.success( `Re-sort prep: ${result.recovered} to INBOX, MailFlow keywords stripped on ${result.mailFlowKeywordsStripped} message(s)` ) respond.success(res, { recovered: result.recovered, folders: result.folders, mailFlowKeywordsStripped: result.mailFlowKeywordsStripped, message: result.recovered > 0 || result.mailFlowKeywordsStripped > 0 ? `Moved ${result.recovered} message(s) to INBOX; stripped MailFlow tags from ${result.mailFlowKeywordsStripped} INBOX message(s). Run sort again.` : 'Nothing to reset — INBOX already clean of sort folders / keywords.', }) } finally { try { await imap.close() } catch { /* ignore */ } } }) ) /** * 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/mailflow-labels * Cleanup old MailFlow labels from Gmail (legacy label names) */ router.post('/cleanup/mailflow-labels', validate({ body: { accountId: [rules.required('accountId')], }, }), asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { 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 "MailFlow/..." labels were deleted` : 'No old labels found' }) }) ) /** * GET /api/email/digest * Get today's sorting digest summary * (Also registered on app in index.mjs before router mount.) */ export async function handleGetDigest(req, res) { const userId = req.appwriteUser.id const emptyDigest = () => respond.success(res, { date: new Date().toISOString(), totalSorted: 0, inboxCleared: 0, timeSavedMinutes: 0, stats: {}, highlights: [], suggestions: [], hasData: false, }) try { const digest = await emailDigests.getByUserToday(userId) if (!digest) { return emptyDigest() } return 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, }) } catch (err) { if (isAppwriteCollectionMissing(err)) { return emptyDigest() } throw err } } router.get('/digest', asyncHandler(handleGetDigest)) /** * GET /api/email/digest/history * Get digest history for the last N days */ router.get('/digest/history', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const days = req.query.days ?? 7 const digests = await emailDigests.getByUserRecent(userId, parseInt(String(days), 10)) // 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) })) /** * GET /api/email/:accountId/cleanup/preview * Dry-run: messages that would be affected by cleanup settings (no mutations). * * curl examples: * curl -s -H "Authorization: Bearer YOUR_JWT" "http://localhost:3000/api/email/ACCOUNT_DOC_ID/cleanup/preview" */ router.get('/:accountId/cleanup/preview', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { accountId } = req.params const account = await emailAccounts.get(accountId) if (account.userId !== userId) { throw new AuthorizationError('No permission for this account') } const prefs = await userPreferences.getByUser(userId) const cleanup = prefs?.preferences?.cleanup || userPreferences.getDefaults().cleanup const maxList = cleanup.safety?.maxDeletesPerRun ?? 100 const messages = [] if (cleanup.readItems?.enabled) { const readList = await listReadCleanupPreviewMessages(account, cleanup.readItems.gracePeriodDays, maxList) for (const m of readList) { if (messages.length >= maxList) break messages.push({ ...m, reason: 'read' }) } } if (cleanup.promotions?.enabled && messages.length < maxList) { const promoList = await listPromotionCleanupPreviewMessages( account, cleanup.promotions.deleteAfterDays, cleanup.promotions.matchCategoriesOrLabels || [], maxList - messages.length ) for (const m of promoList) { if (messages.length >= maxList) break messages.push({ ...m, reason: 'promotion' }) } } respond.success(res, { messages, count: messages.length }) })) /** * GET /api/email/:accountId/cleanup/status * * curl examples: * curl -s -H "Authorization: Bearer YOUR_JWT" "http://localhost:3000/api/email/ACCOUNT_DOC_ID/cleanup/status" */ router.get('/:accountId/cleanup/status', asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { accountId } = req.params const account = await emailAccounts.get(accountId) if (account.userId !== userId) { throw new AuthorizationError('No permission for this account') } const prefs = await userPreferences.getByUser(userId) const meta = prefs?.preferences?.cleanupMeta || {} respond.success(res, { lastRun: meta.lastRun, lastRunCounts: meta.lastRunCounts, lastErrors: meta.lastErrors, }) })) /** * 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.appwriteUser.id log.info('Cleanup job started', { userId }) const results = { usersProcessed: 0, emailsProcessed: { readItems: 0, promotions: 0, }, errors: [], } try { 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 if (cleanup.readItems?.enabled) { const readItemsCount = await processReadItemsCleanup( account, cleanup.readItems.action, cleanup.readItems.gracePeriodDays ) results.emailsProcessed.readItems += readItemsCount } 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 }) } } const lastRun = new Date().toISOString() await userPreferences.upsert(userId, { cleanupMeta: { lastRun, lastRunCounts: { readItems: results.emailsProcessed.readItems, promotions: results.emailsProcessed.promotions, }, lastErrors: results.errors.map((e) => e.error), }, }) 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 MailFlow labels matching the categories const labelQueries = matchCategories.map(cat => `label:MailFlow/${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 } async function listReadCleanupPreviewMessages(account, gracePeriodDays, cap) { const out = [] const cutoffDate = new Date() cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays) const before = `${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}` try { if (account.provider === 'gmail') { const gmail = await getGmailService(account.accessToken, account.refreshToken) const query = `-is:unread before:${before}` const response = await gmail.gmail.users.messages.list({ userId: 'me', q: query, maxResults: Math.min(cap, 500), }) const ids = (response.data.messages || []).map((m) => m.id).slice(0, cap) const emails = await gmail.batchGetEmails(ids) for (const email of emails) { out.push({ id: email.id, subject: email.headers?.subject || '', from: email.headers?.from || '', date: email.headers?.date || email.internalDate || '', }) } } else if (account.provider === 'outlook') { const outlook = await getOutlookService(account.accessToken) const filter = `isRead eq true and receivedDateTime lt ${cutoffDate.toISOString()}` const data = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=${Math.min(cap, 500)}`) for (const message of data.value || []) { out.push({ id: message.id, subject: message.subject || '', from: message.from?.emailAddress?.address || '', date: message.receivedDateTime || '', }) if (out.length >= cap) break } } } catch (err) { log.warn('listReadCleanupPreviewMessages failed', { error: err.message }) } return out.slice(0, cap) } async function listPromotionCleanupPreviewMessages(account, deleteAfterDays, matchCategories, cap) { const out = [] const cutoffDate = new Date() cutoffDate.setDate(cutoffDate.getDate() - deleteAfterDays) const before = `${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}` try { if (account.provider === 'gmail' && matchCategories.length > 0) { const gmail = await getGmailService(account.accessToken, account.refreshToken) const labelQueries = matchCategories.map((cat) => `label:MailFlow/${cat}`).join(' OR ') const query = `(${labelQueries}) before:${before}` const response = await gmail.gmail.users.messages.list({ userId: 'me', q: query, maxResults: Math.min(cap, 500), }) const ids = (response.data.messages || []).map((m) => m.id).slice(0, cap) const emails = await gmail.batchGetEmails(ids) for (const email of emails) { out.push({ id: email.id, subject: email.headers?.subject || '', from: email.headers?.from || '', date: email.headers?.date || email.internalDate || '', }) } } else if (account.provider === 'outlook' && cap > 0) { const outlook = await getOutlookService(account.accessToken) const filter = `receivedDateTime lt ${cutoffDate.toISOString()}` const data = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=${Math.min(cap, 500)}`) for (const message of data.value || []) { const cats = message.categories || [] const match = matchCategories.length === 0 || cats.some((c) => matchCategories.includes(c)) if (!match) continue out.push({ id: message.id, subject: message.subject || '', from: message.from?.emailAddress?.address || '', date: message.receivedDateTime || '', }) if (out.length >= cap) break } } } catch (err) { log.warn('listPromotionCleanupPreviewMessages failed', { error: err.message }) } return out.slice(0, cap) } export default router