Files
Emailsorter/server/routes/email.mjs
ANDJ 89bc86b615 Try
dfssdfsfdsf
2026-04-09 21:00:04 +02:00

2100 lines
74 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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