/** * Email Routes * Email account management and sorting */ import express from 'express' import { asyncHandler, NotFoundError, AuthorizationError, ValidationError } from '../middleware/errorHandler.mjs' import { validate, rules } from '../middleware/validate.mjs' import { limiters } from '../middleware/rateLimit.mjs' import { respond } from '../utils/response.mjs' import { emailAccounts, emailStats, emailDigests, userPreferences } from '../services/database.mjs' import { config, features } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' const router = express.Router() // Lazy load heavy services let gmailServiceClass = null let outlookServiceClass = null let aiSorterInstance = null async function getGmailService(accessToken, refreshToken) { if (!gmailServiceClass) { const { GmailService } = await import('../services/gmail.mjs') gmailServiceClass = GmailService } return new gmailServiceClass(accessToken, refreshToken) } async function getOutlookService(accessToken) { if (!outlookServiceClass) { const { OutlookService } = await import('../services/outlook.mjs') outlookServiceClass = OutlookService } return new outlookServiceClass(accessToken) } async function getAISorter() { if (!aiSorterInstance) { const { AISorterService } = await import('../services/ai-sorter.mjs') aiSorterInstance = new AISorterService() } return aiSorterInstance } // ═══════════════════════════════════════════════════════════════════════════ // DEMO DATA - Realistic Test Emails // ═══════════════════════════════════════════════════════════════════════════ const DEMO_EMAILS = [ { from: 'boss@company.com', subject: 'Urgent: Meeting tomorrow 9 AM', snippet: 'Hi, we need to discuss the new project urgently...' }, { from: 'billing@amazon.com', subject: 'Your invoice for Order #US123456', snippet: 'Thank you for your order. Please find your invoice attached...' }, { from: 'newsletter@techcrunch.com', subject: 'TechCrunch Weekly: AI Revolution 2026', snippet: 'This week in Tech: OpenAI announces new model...' }, { from: 'noreply@linkedin.com', subject: '5 new connection requests', snippet: 'You have new connection requests from: John Smith, Jane Doe...' }, { from: 'support@stripe.com', subject: 'Payment confirmation: $49.00', snippet: 'Your payment was successfully processed...' }, { from: 'client@acme-corp.com', subject: 'RE: Web development proposal', snippet: 'Thank you for your proposal. We have a few questions...' }, { from: 'noreply@google.com', subject: 'Security alert: New sign-in detected', snippet: 'We detected a new sign-in from Windows Chrome...' }, { from: 'marketing@shopify.com', subject: '50% off today only!', snippet: 'Exclusive offer for our premium customers...' }, { from: 'team@slack.com', subject: 'New message in #general', snippet: '@Max posted a message: Hey team, how\'s it going...' }, { from: 'calendar@gmail.com', subject: 'Invitation: Project meeting (Monday 2 PM)', snippet: 'You have been invited to a meeting...' }, { from: 'john.doe@gmail.com', subject: 'Important: Documents needed by tomorrow', snippet: 'Hi, could you please send the documents by tomorrow morning...' }, { from: 'billing@verizon.com', subject: 'Your phone bill January 2026', snippet: 'Dear customer, your bill is ready for download...' }, { from: 'deals@ebay.com', subject: 'Your Watchlist: Price drops!', snippet: '3 items on your watchlist are now cheaper...' }, { from: 'no-reply@github.com', subject: '[GitHub] Security alert: Dependabot', snippet: 'We found a potential security vulnerability in...' }, { from: 'newsletter@wired.com', subject: 'Wired Weekly: Cloud Computing Trends', snippet: 'The most important tech news of the week...' }, { from: 'support@bank.com', subject: 'Account statement January 2026', snippet: 'Your monthly account statement is ready...' }, { from: 'noreply@twitter.com', subject: '@TechNews replied to your tweet', snippet: 'TechNews: "That\'s an interesting perspective..."' }, { from: 'info@ups.com', subject: 'Your package is on the way!', snippet: 'Tracking number: 1Z999AA10123456784, Expected delivery...' }, { from: 'team@notion.so', subject: 'New comments in your workspace', snippet: 'Anna added a comment to "Project Plan"...' }, { from: 'service@insurance.com', subject: 'Your policy has been updated', snippet: 'Dear customer, we have made changes to your policy...' }, ] /** * POST /api/email/connect * Connect a new email account */ router.post('/connect', validate({ body: { userId: [rules.required('userId')], provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo'])], email: [rules.required('email'), rules.email()], }, }), asyncHandler(async (req, res) => { const { userId, provider, email, accessToken, refreshToken, expiresAt } = req.body // Check if account already exists const existingAccounts = await emailAccounts.getByUser(userId) const alreadyConnected = existingAccounts.find(a => a.email === email) if (alreadyConnected) { throw new ValidationError('This email account is already connected', { email: ['Already connected'], }) } // Create account const account = await emailAccounts.create({ userId, provider, email, accessToken: accessToken || '', refreshToken: refreshToken || '', expiresAt: expiresAt || 0, isActive: true, lastSync: null, }) log.success(`Email account connected: ${email} (${provider})`) respond.created(res, { accountId: account.$id, email: account.email, provider: account.provider, }) }) ) /** * POST /api/email/connect-demo * Connect a demo email account for testing */ router.post('/connect-demo', validate({ body: { userId: [rules.required('userId')], }, }), asyncHandler(async (req, res) => { const { userId } = req.body const demoEmail = `demo-${userId.slice(0, 8)}@emailsorter.demo` // Check if demo account already exists const existingAccounts = await emailAccounts.getByUser(userId) const alreadyConnected = existingAccounts.find(a => a.provider === 'demo') if (alreadyConnected) { return respond.success(res, { accountId: alreadyConnected.$id, email: alreadyConnected.email, provider: 'demo', message: 'Demo account already connected', }) } // Create demo account const account = await emailAccounts.create({ userId, provider: 'demo', email: demoEmail, accessToken: 'demo-token', refreshToken: '', expiresAt: 0, isActive: true, lastSync: null, }) log.success(`Demo account created for user ${userId}`) respond.created(res, { accountId: account.$id, email: account.email, provider: 'demo', message: 'Demo account successfully created', }) }) ) /** * GET /api/email/accounts * Get user's connected email accounts */ router.get('/accounts', asyncHandler(async (req, res) => { const { userId } = req.query if (!userId) { throw new ValidationError('userId is required') } const accounts = await emailAccounts.getByUser(userId) // Don't expose tokens const safeAccounts = accounts.map(acc => ({ id: acc.$id, email: acc.email, provider: acc.provider, connected: true, lastSync: acc.lastSync, isDemo: acc.provider === 'demo', })) respond.success(res, safeAccounts) })) /** * DELETE /api/email/accounts/:accountId * Disconnect an email account */ router.delete('/accounts/:accountId', asyncHandler(async (req, res) => { const { accountId } = req.params const { userId } = req.query if (!userId) { throw new ValidationError('userId is required') } // Verify ownership const account = await emailAccounts.get(accountId) if (account.userId !== userId) { throw new AuthorizationError('No permission for this account') } await emailAccounts.deactivate(accountId) log.info(`Email account disconnected: ${account.email}`) respond.success(res, null, 'Account successfully disconnected') })) /** * GET /api/email/stats * Get email sorting statistics */ router.get('/stats', asyncHandler(async (req, res) => { const { userId } = req.query if (!userId) { throw new ValidationError('userId is required') } const stats = await emailStats.getByUser(userId) if (!stats) { // Return empty stats for new users return respond.success(res, { totalSorted: 0, todaySorted: 0, weekSorted: 0, categories: {}, timeSaved: 0, }) } respond.success(res, { totalSorted: stats.totalSorted || 0, todaySorted: stats.todaySorted || 0, weekSorted: stats.weekSorted || 0, categories: stats.categoriesJson ? JSON.parse(stats.categoriesJson) : {}, timeSaved: stats.timeSavedMinutes || 0, }) })) /** * POST /api/email/sort * Trigger email sorting for an account * * Options: * - maxEmails: Maximum emails to process (default: 500, max: 2000) * - processAll: If true, process entire inbox with pagination */ router.post('/sort', limiters.emailSort, validate({ body: { userId: [rules.required('userId')], accountId: [rules.required('accountId')], }, }), asyncHandler(async (req, res) => { const { userId, accountId, maxEmails = 500, processAll = true } = req.body const effectiveMax = Math.min(maxEmails, 2000) // Cap at 2000 emails // Get account const account = await emailAccounts.get(accountId) if (account.userId !== userId) { throw new AuthorizationError('No permission for this account') } // Get user preferences const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || {} // Get AI sorter const sorter = await getAISorter() let sortedCount = 0 const results = { byCategory: {} } // ═══════════════════════════════════════════════════════════════════════ // DEMO MODE - Sorting with simulated emails // ═══════════════════════════════════════════════════════════════════════ if (account.provider === 'demo') { log.info(`Demo sorting started for user ${userId}`) // Select random emails from the demo pool const emailCount = Math.min(maxEmails, DEMO_EMAILS.length) const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5) const emailsToSort = shuffled.slice(0, emailCount) // Check if AI is available if (features.ai()) { // Real AI sorting with demo data const classified = await sorter.batchCategorize(emailsToSort, preferences) for (const { classification } of classified) { const category = classification.category sortedCount++ results.byCategory[category] = (results.byCategory[category] || 0) + 1 } log.success(`AI sorting completed: ${sortedCount} demo emails`) } else { // Fallback without AI - simulated categorization const categoryDistribution = { vip: 0.1, customers: 0.15, invoices: 0.15, newsletters: 0.2, social: 0.15, promotions: 0.1, security: 0.05, calendar: 0.05, review: 0.05, } for (const email of emailsToSort) { // Simple rule-based categorization let category = 'review' const from = email.from.toLowerCase() const subject = email.subject.toLowerCase() if (from.includes('boss') || from.includes('john.doe')) { category = 'vip' } else if (from.includes('billing') || subject.includes('invoice') || subject.includes('bill')) { category = 'invoices' } else if (from.includes('newsletter') || subject.includes('weekly') || subject.includes('news')) { category = 'newsletters' } else if (from.includes('linkedin') || from.includes('twitter') || from.includes('slack')) { category = 'social' } else if (from.includes('client') || subject.includes('proposal') || subject.includes('project')) { category = 'customers' } else if (from.includes('security') || subject.includes('security') || subject.includes('sign-in')) { category = 'security' } else if (subject.includes('off') || subject.includes('deal') || from.includes('marketing')) { category = 'promotions' } else if (subject.includes('invitation') || subject.includes('meeting') || subject.includes('calendar')) { category = 'calendar' } sortedCount++ results.byCategory[category] = (results.byCategory[category] || 0) + 1 } log.success(`Rule-based sorting completed: ${sortedCount} demo emails`) } } // ═══════════════════════════════════════════════════════════════════════ // GMAIL - Real Gmail sorting with native categories // ═══════════════════════════════════════════════════════════════════════ else if (account.provider === 'gmail') { if (!features.ai()) { throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.') } if (!account.accessToken) { throw new ValidationError('Gmail account needs to be reconnected') } log.info(`Gmail sorting started for ${account.email}`) try { const gmail = await getGmailService(account.accessToken, account.refreshToken) // FIRST: Clean up old "EmailSorter/..." labels const deletedLabels = await gmail.cleanupOldLabels() if (deletedLabels > 0) { log.success(`${deletedLabels} old labels cleaned up`) } // Create labels only for categories without native Gmail category const categories = sorter.getCategories() const labelMap = {} for (const [key, cat] of Object.entries(categories)) { // Wenn Gmail-Kategorie existiert, diese verwenden const gmailCat = sorter.getGmailCategory(key) if (gmailCat) { labelMap[key] = gmailCat // z.B. CATEGORY_SOCIAL } else { // Create custom label (clean name without prefix) try { const label = await gmail.createLabel(cat.name, cat.color) if (label) { labelMap[key] = label.id } } catch (err) { log.warn(`Failed to create label: ${cat.name}`) } } } // Fetch and process ALL emails with pagination let pageToken = null let totalProcessed = 0 const batchSize = 100 // Gmail API batch size do { // Fetch batch of messages const { messages, nextPageToken } = await gmail.listEmails( batchSize, pageToken, 'in:inbox' // All inbox emails ) pageToken = nextPageToken if (!messages?.length) break // Get full email details const emails = await gmail.batchGetEmails(messages.map(m => m.id)) // Classify emails with AI const classified = await sorter.batchCategorize( emails.map(e => ({ from: e.headers?.from || '', subject: e.headers?.subject || '', snippet: e.snippet || '', })), preferences ) // Apply labels/categories and actions for (let i = 0; i < classified.length; i++) { const email = emails[i] const { category } = classified[i].classification const action = sorter.getCategoryAction(category) try { const labelsToAdd = [] const labelsToRemove = [] // Add label/category if (labelMap[category]) { labelsToAdd.push(labelMap[category]) } // Handle different actions switch (action) { case 'star': // Keep in inbox + add star labelsToAdd.push('STARRED') break case 'archive_read': // Archive + mark as read (clean inbox) labelsToRemove.push('INBOX', 'UNREAD') break case 'archive': // Just archive (legacy) labelsToRemove.push('INBOX') break case 'inbox': default: // Keep in inbox, no changes needed break } // Apply label changes if (labelsToAdd.length > 0) { await gmail.addLabels(email.id, labelsToAdd) } if (labelsToRemove.length > 0) { await gmail.removeLabels(email.id, labelsToRemove) } sortedCount++ results.byCategory[category] = (results.byCategory[category] || 0) + 1 } catch (err) { log.warn(`Email sorting failed: ${email.id}`, { error: err.message }) } } totalProcessed += emails.length log.info(`Processed ${totalProcessed} emails so far...`) // Stop if we've hit the limit or there's no more pages if (totalProcessed >= effectiveMax) { log.info(`Reached limit of ${effectiveMax} emails`) break } // Small delay between batches to avoid rate limiting if (pageToken) { await new Promise(r => setTimeout(r, 200)) } } while (pageToken && processAll) log.success(`Gmail sorting completed: ${sortedCount} emails processed`) } catch (err) { log.error('Gmail sorting failed', { error: err.message }) throw new ValidationError(`Gmail error: ${err.message}. Please reconnect account.`) } } // ═══════════════════════════════════════════════════════════════════════ // OUTLOOK - Real Outlook sorting // ═══════════════════════════════════════════════════════════════════════ else if (account.provider === 'outlook') { if (!features.ai()) { throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.') } if (!account.accessToken) { throw new ValidationError('Outlook account needs to be reconnected') } log.info(`Outlook sorting started for ${account.email}`) try { const outlook = await getOutlookService(account.accessToken) // Fetch and process ALL emails with pagination let skipToken = null let totalProcessed = 0 const batchSize = 100 do { // Fetch batch of messages const { messages, nextLink } = await outlook.listEmails(batchSize, skipToken) skipToken = nextLink if (!messages?.length) break // Classify emails with AI const classified = await sorter.batchCategorize( messages.map(e => ({ from: e.from?.emailAddress?.address || '', subject: e.subject || '', snippet: e.bodyPreview || '', })), preferences ) // Apply categories and actions for (let i = 0; i < classified.length; i++) { const email = messages[i] const { category } = classified[i].classification const action = sorter.getCategoryAction(category) const catName = sorter.getLabelName(category) try { await outlook.addCategories(email.id, [catName]) // Handle different actions switch (action) { case 'archive_read': // Archive + mark as read await outlook.archiveEmail(email.id) await outlook.markAsRead(email.id) break case 'archive': await outlook.archiveEmail(email.id) break case 'star': await outlook.flagEmail(email.id) break } sortedCount++ results.byCategory[category] = (results.byCategory[category] || 0) + 1 } catch (err) { log.warn(`Email sorting failed: ${email.id}`) } } totalProcessed += messages.length log.info(`Processed ${totalProcessed} emails so far...`) // Stop if we've hit the limit if (totalProcessed >= effectiveMax) { log.info(`Reached limit of ${effectiveMax} emails`) break } // Small delay between batches if (skipToken) { await new Promise(r => setTimeout(r, 200)) } } while (skipToken && processAll) log.success(`Outlook sorting completed: ${sortedCount} emails processed`) } catch (err) { log.error('Outlook sorting failed', { error: err.message }) throw new ValidationError(`Outlook error: ${err.message}. Please reconnect account.`) } } // Update last sync await emailAccounts.updateLastSync(accountId) // Update stats const timeSaved = Math.round(sortedCount * 0.25) // 15 seconds per email await emailStats.increment(userId, { total: sortedCount, today: sortedCount, week: sortedCount, timeSaved, }) // Update categories in stats try { const currentStats = await emailStats.getByUser(userId) if (currentStats) { const existingCategories = currentStats.categoriesJson ? JSON.parse(currentStats.categoriesJson) : {} for (const [cat, count] of Object.entries(results.byCategory)) { existingCategories[cat] = (existingCategories[cat] || 0) + count } await emailStats.updateCategories(userId, existingCategories) } } catch (err) { log.warn('Category update failed', { error: err.message }) } // Calculate inbox cleared (archived emails) const archivedCategories = ['newsletters', 'promotions', 'social'] const inboxCleared = archivedCategories.reduce((sum, cat) => sum + (results.byCategory[cat] || 0), 0 ) // Generate digest highlights (important emails) const highlights = [] if (results.byCategory.vip > 0) { highlights.push({ type: 'vip', count: results.byCategory.vip, message: `${results.byCategory.vip} important emails need attention` }) } if (results.byCategory.security > 0) { highlights.push({ type: 'security', count: results.byCategory.security, message: `${results.byCategory.security} security notifications` }) } if (results.byCategory.invoices > 0) { highlights.push({ type: 'invoices', count: results.byCategory.invoices, message: `${results.byCategory.invoices} invoices received` }) } // Generate suggestions (potential unsubscribes) const suggestions = [] if (results.byCategory.newsletters > 5) { suggestions.push({ type: 'unsubscribe', message: 'Consider unsubscribing from some newsletters to reduce inbox clutter' }) } if (results.byCategory.promotions > 10) { suggestions.push({ type: 'unsubscribe', message: 'High volume of promotional emails - review subscriptions' }) } // Update daily digest try { await emailDigests.updateToday(userId, { stats: results.byCategory, highlights, suggestions, totalSorted: sortedCount, inboxCleared, timeSavedMinutes: timeSaved, }) } catch (err) { log.warn('Digest update failed', { error: err.message }) } log.success(`Sorting completed: ${sortedCount} emails for ${account.email}`) respond.success(res, { sorted: sortedCount, inboxCleared, categories: results.byCategory, timeSaved: { minutes: timeSaved, formatted: timeSaved > 0 ? `${timeSaved} minutes` : '< 1 minute', }, highlights, suggestions, provider: account.provider, isDemo: account.provider === 'demo', }) }) ) /** * POST /api/email/sort-demo * Quick demo sorting without account (for testing) */ router.post('/sort-demo', asyncHandler(async (req, res) => { const { count = 10 } = req.body log.info(`Quick demo sorting: ${count} emails`) // Get AI sorter const sorter = await getAISorter() const results = { byCategory: {} } // Select random emails const emailCount = Math.min(count, DEMO_EMAILS.length) const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5) const emailsToSort = shuffled.slice(0, emailCount) if (features.ai()) { // Real AI sorting const classified = await sorter.batchCategorize(emailsToSort, {}) const sortedEmails = emailsToSort.map((email, i) => ({ ...email, category: classified[i].classification.category, categoryName: sorter.getLabelName(classified[i].classification.category), confidence: classified[i].classification.confidence, reason: classified[i].classification.reason, })) for (const email of sortedEmails) { results.byCategory[email.category] = (results.byCategory[email.category] || 0) + 1 } respond.success(res, { sorted: emailCount, emails: sortedEmails, categories: results.byCategory, aiEnabled: true, }) } else { // Rule-based sorting const sortedEmails = emailsToSort.map(email => { let category = 'review' const from = email.from.toLowerCase() const subject = email.subject.toLowerCase() if (from.includes('boss') || from.includes('john.doe')) { category = 'vip' } else if (from.includes('billing') || subject.includes('invoice')) { category = 'invoices' } else if (from.includes('newsletter') || subject.includes('weekly')) { category = 'newsletters' } else if (from.includes('linkedin') || from.includes('twitter')) { category = 'social' } else if (from.includes('client') || subject.includes('proposal')) { category = 'customers' } else if (subject.includes('security') || subject.includes('sign-in')) { category = 'security' } else if (subject.includes('off') || from.includes('marketing')) { category = 'promotions' } else if (subject.includes('invitation') || subject.includes('meeting')) { category = 'calendar' } results.byCategory[category] = (results.byCategory[category] || 0) + 1 return { ...email, category, categoryName: sorter.getLabelName(category), } }) respond.success(res, { sorted: emailCount, emails: sortedEmails, categories: results.byCategory, aiEnabled: false, message: 'AI not configured - rule-based sorting used', }) } })) /** * POST /api/email/cleanup * Cleanup old EmailSorter labels from Gmail */ router.post('/cleanup', validate({ body: { userId: [rules.required('userId')], accountId: [rules.required('accountId')], }, }), asyncHandler(async (req, res) => { const { userId, accountId } = req.body const account = await emailAccounts.get(accountId) if (account.userId !== userId) { throw new AuthorizationError('No permission for this account') } if (account.provider !== 'gmail') { return respond.success(res, { deleted: 0, message: 'Cleanup only available for Gmail' }) } log.info(`Label cleanup started for ${account.email}`) const gmail = await getGmailService(account.accessToken, account.refreshToken) const deleted = await gmail.cleanupOldLabels() log.success(`${deleted} old labels deleted`) respond.success(res, { deleted, message: deleted > 0 ? `${deleted} old "EmailSorter/..." labels were deleted` : 'No old labels found' }) }) ) /** * GET /api/email/digest * Get today's sorting digest summary */ router.get('/digest', asyncHandler(async (req, res) => { const { userId } = req.query if (!userId) { throw new ValidationError('userId is required') } const digest = await emailDigests.getByUserToday(userId) if (!digest) { // Return empty digest for new users return respond.success(res, { date: new Date().toISOString().split('T')[0], totalSorted: 0, inboxCleared: 0, timeSavedMinutes: 0, stats: {}, highlights: [], suggestions: [], hasData: false, }) } respond.success(res, { date: digest.date, totalSorted: digest.totalSorted, inboxCleared: digest.inboxCleared, timeSavedMinutes: digest.timeSavedMinutes, stats: digest.stats, highlights: digest.highlights, suggestions: digest.suggestions, hasData: true, }) })) /** * GET /api/email/digest/history * Get digest history for the last N days */ router.get('/digest/history', asyncHandler(async (req, res) => { const { userId, days = 7 } = req.query if (!userId) { throw new ValidationError('userId is required') } const digests = await emailDigests.getByUserRecent(userId, parseInt(days)) // Calculate totals const totals = { totalSorted: digests.reduce((sum, d) => sum + (d.totalSorted || 0), 0), inboxCleared: digests.reduce((sum, d) => sum + (d.inboxCleared || 0), 0), timeSavedMinutes: digests.reduce((sum, d) => sum + (d.timeSavedMinutes || 0), 0), } respond.success(res, { days: parseInt(days), digests: digests.map(d => ({ date: d.date, totalSorted: d.totalSorted, inboxCleared: d.inboxCleared, timeSavedMinutes: d.timeSavedMinutes, stats: d.stats, })), totals, }) })) /** * GET /api/email/categories * Get all available categories */ router.get('/categories', asyncHandler(async (req, res) => { const sorter = await getAISorter() const categories = sorter.getCategories() const formattedCategories = Object.entries(categories).map(([key, cat]) => ({ id: key, name: cat.name, description: cat.description, color: cat.color, action: cat.action, priority: cat.priority, })) respond.success(res, formattedCategories) })) /** * POST /api/email/webhook/gmail * Gmail push notification webhook */ router.post('/webhook/gmail', asyncHandler(async (req, res) => { // Acknowledge immediately res.status(200).send() const { message } = req.body if (!message?.data) return try { const data = JSON.parse(Buffer.from(message.data, 'base64').toString()) log.info(`Gmail Webhook: ${data.emailAddress}`, { historyId: data.historyId }) // Queue processing in production } catch (err) { log.error('Gmail Webhook Error', { error: err.message }) } })) /** * POST /api/email/webhook/outlook * Microsoft Graph webhook */ router.post('/webhook/outlook', asyncHandler(async (req, res) => { // Handle validation request if (req.query.validationToken) { return res.status(200).send(req.query.validationToken) } res.status(202).send() const { value } = req.body if (!value?.length) return for (const notification of value) { log.info(`Outlook Webhook: ${notification.changeType}`, { resource: notification.resource }) // Queue processing in production } })) export default router