/** * MailFlow Backend Server * Main entry point */ import 'dotenv/config' import express from 'express' import cors from 'cors' import { fileURLToPath } from 'url' import { dirname, join } from 'path' // Config & Middleware import { config, validateConfig } from './config/index.mjs' import { errorHandler, asyncHandler, AppError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs' import { respond } from './utils/response.mjs' import { logger, log } from './middleware/logger.mjs' import { limiters } from './middleware/rateLimit.mjs' import { requireAuth, requireAuthUnlessEmailWebhook } from './middleware/auth.mjs' // Routes import oauthRoutes from './routes/oauth.mjs' import emailRoutes from './routes/email.mjs' import { handleGetDigest } from './routes/email.mjs' import stripeRoutes from './routes/stripe.mjs' import { handleGetSubscriptionStatus } from './routes/stripe.mjs' import apiRoutes from './routes/api.mjs' import { handleGetReferralCode } from './routes/api.mjs' import analyticsRoutes from './routes/analytics.mjs' import webhookRoutes from './routes/webhook.mjs' import { startCounterJobs } from './jobs/reset-counters.mjs' import { startAutoSortJob } from './jobs/auto-sort.mjs' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) // Validate configuration validateConfig() // Create Express app const app = express() // Trust proxy (for rate limiting behind reverse proxy) app.set('trust proxy', 1) // Request ID middleware app.use((req, res, next) => { req.id = Math.random().toString(36).substring(2, 15) res.setHeader('X-Request-ID', req.id) next() }) // Safety net (before all routers): collapse /api/api → /api in path until stable — old clients / bad env app.use((req, _res, next) => { const [pathPart, ...q] = req.originalUrl.split('?') let p = pathPart let prev = '' while (p !== prev && p.includes('/api/api')) { prev = p p = p.replace(/\/api\/api/g, '/api') } if (p !== pathPart) { req.url = q.length ? `${p}?${q.join('?')}` : p } next() }) // CORS app.use(cors(config.cors)) // Request logging app.use(logger({ skip: (req) => req.path === '/api/health' || req.path.startsWith('/assets'), })) // Rate limiting app.use('/api', limiters.api) // Static files app.use(express.static(join(__dirname, '..', 'public'))) // Gitea webhook: raw body for X-Gitea-Signature (must match signed bytes exactly). // type: () => true — Gitea may send application/json; charset=utf-8 or similar; strict 'application/json' can skip parsing and leave body empty. app.use('/api/webhook', express.raw({ type: () => true, limit: '2mb' })) app.use('/api/webhook', webhookRoutes) // Body parsing (BEFORE routes, AFTER static) // Note: Stripe webhook needs raw body, handled in stripe routes app.use('/api', express.json({ limit: '1mb' })) app.use('/api', express.urlencoded({ extended: true })) // Health check (no rate limit) app.get('/api/health', (req, res) => { res.json({ success: true, data: { service: 'mailflow-api', status: 'healthy', port: config.port, timestamp: new Date().toISOString(), version: process.env.npm_package_version || '1.0.0', environment: config.nodeEnv, uptime: Math.floor(process.uptime()), }, }) }) /* * Route index — these three are implemented in routes/*.mjs and registered here FIRST * (before app.use mounts) so GET always matches real handlers, not the JSON 404 catch-all. * * GET /api/email/digest → handleGetDigest (routes/email.mjs) * GET /api/subscription/status → handleGetSubscriptionStatus (routes/stripe.mjs) * GET /api/referrals/code → handleGetReferralCode (routes/api.mjs) */ app.get('/api/email/digest', requireAuthUnlessEmailWebhook, asyncHandler(handleGetDigest)) app.get('/api/subscription/status', requireAuth, asyncHandler(handleGetSubscriptionStatus)) app.get('/api/referrals/code', requireAuth, asyncHandler(handleGetReferralCode)) // API Routes app.use('/api/oauth', oauthRoutes) app.use('/api/email', emailRoutes) app.use('/api/subscription', stripeRoutes) app.use('/api/analytics', analyticsRoutes) app.use('/api', apiRoutes) // Preferences endpoints (inline for simplicity) import { userPreferences } from './services/database.mjs' import { isAdmin } from './config/index.mjs' /** * GET /api/me * Returns current user context (JWT). isAdmin from verified email. */ app.get('/api/me', requireAuth, asyncHandler(async (req, res) => { respond.success(res, { isAdmin: isAdmin(req.appwriteUser.email) }) })) app.get('/api/preferences', requireAuth, asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const prefs = await userPreferences.getByUser(userId) respond.success(res, prefs?.preferences || { vipSenders: [], blockedSenders: [], customRules: [], priorityTopics: [], }) })) app.post('/api/preferences', requireAuth, asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { ...preferences } = req.body await userPreferences.upsert(userId, preferences) respond.success(res, null, 'Einstellungen gespeichert') })) /** * PATCH /api/preferences/profile * { displayName?, timezone?, notificationPrefs? } */ app.patch('/api/preferences/profile', requireAuth, asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { displayName, timezone, notificationPrefs } = req.body const prefs = await userPreferences.getByUser(userId) const current = prefs?.preferences?.profile || userPreferences.getDefaults().profile const profile = { ...current, ...(displayName !== undefined && { displayName }), ...(timezone !== undefined && { timezone }), ...(notificationPrefs !== undefined && { notificationPrefs }), } await userPreferences.upsert(userId, { profile }) respond.success(res, { profile }, 'Profile saved') })) /** * GET /api/preferences/ai-control * Get AI Control settings */ app.get('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() respond.success(res, { enabledCategories: preferences.enabledCategories || [], categoryActions: preferences.categoryActions || {}, autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : true, cleanup: preferences.cleanup || userPreferences.getDefaults().cleanup, }) })) /** * POST /api/preferences/ai-control * Save AI Control settings */ app.post('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body const updates = {} if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories if (categoryActions !== undefined) updates.categoryActions = categoryActions if (autoDetectCompanies !== undefined) updates.autoDetectCompanies = autoDetectCompanies if (cleanup !== undefined) updates.cleanup = cleanup await userPreferences.upsert(userId, updates) respond.success(res, null, 'AI Control settings saved') })) /** * GET /api/preferences/company-labels * Get company labels */ app.get('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() respond.success(res, preferences.companyLabels || []) })) /** * POST /api/preferences/company-labels * Save/Update company label */ app.post('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { companyLabel } = req.body if (!companyLabel) throw new ValidationError('companyLabel is required') const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() const companyLabels = preferences.companyLabels || [] // Generate ID if not provided if (!companyLabel.id) { companyLabel.id = `label_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` } // Update or add label const existingIndex = companyLabels.findIndex(l => l.id === companyLabel.id) if (existingIndex >= 0) { companyLabels[existingIndex] = companyLabel } else { companyLabels.push(companyLabel) } await userPreferences.upsert(userId, { companyLabels }) respond.success(res, companyLabel, 'Company label saved') })) /** * DELETE /api/preferences/company-labels/:id * Delete company label */ app.delete('/api/preferences/company-labels/:id', requireAuth, asyncHandler(async (req, res) => { const userId = req.appwriteUser.id const { id } = req.params if (!id) throw new ValidationError('label id is required') const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() const companyLabels = (preferences.companyLabels || []).filter(l => l.id !== id) await userPreferences.upsert(userId, { companyLabels }) respond.success(res, null, 'Company label deleted') })) /** * GET /api/preferences/name-labels * Get name labels (worker labels). Admin only. */ app.get('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => { if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels') const userId = req.appwriteUser.id const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() respond.success(res, preferences.nameLabels || []) })) /** * POST /api/preferences/name-labels * Save/Update name label (worker). Admin only. */ app.post('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => { if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels') const userId = req.appwriteUser.id const { nameLabel } = req.body if (!nameLabel) throw new ValidationError('nameLabel is required') const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() const nameLabels = preferences.nameLabels || [] if (!nameLabel.id) { nameLabel.id = `namelabel_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` } const existingIndex = nameLabels.findIndex(l => l.id === nameLabel.id) if (existingIndex >= 0) { nameLabels[existingIndex] = nameLabel } else { nameLabels.push(nameLabel) } await userPreferences.upsert(userId, { nameLabels }) respond.success(res, nameLabel, 'Name label saved') })) /** * DELETE /api/preferences/name-labels/:id * Delete name label. Admin only. */ app.delete('/api/preferences/name-labels/:id', requireAuth, asyncHandler(async (req, res) => { if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels') const userId = req.appwriteUser.id const { id } = req.params if (!id) throw new ValidationError('label id is required') const prefs = await userPreferences.getByUser(userId) const preferences = prefs?.preferences || userPreferences.getDefaults() const nameLabels = (preferences.nameLabels || []).filter(l => l.id !== id) await userPreferences.upsert(userId, { nameLabels }) respond.success(res, null, 'Name label deleted') })) // Legacy Stripe webhook endpoint app.use('/stripe', stripeRoutes) // SPA fallback: never send index.html for /api (avoids 404/HTML when public/index.html is missing) app.get('*', (req, res, next) => { const pathOnly = req.originalUrl.split('?')[0] if (pathOnly.startsWith('/api')) { console.warn('[404] Unmatched route:', req.method, req.originalUrl) return res.status(404).json({ error: 'Endpoint not found', path: req.originalUrl }) } const indexPath = join(__dirname, '..', 'public', 'index.html') res.sendFile(indexPath, (err) => { if (err) { next( new AppError( 'public/index.html fehlt. In Entwicklung: Frontend über Vite (z. B. http://localhost:5173) starten; für Produktion: Client-Build nach public/ legen.', 404, 'NOT_FOUND' ) ) } }) }) // Catch-all: any method/path that did not send a response (e.g. POST /unknown) app.use((req, res, next) => { if (res.headersSent) { return next() } console.warn('[404] Unmatched route:', req.method, req.originalUrl) res.status(404).json({ error: 'Endpoint not found', path: req.originalUrl }) }) // Global error handler (must be last) app.use(errorHandler) // Graceful shutdown let server function gracefulShutdown(signal) { log.info(`${signal} empfangen, Server wird heruntergefahren...`) server.close(() => { log.info('HTTP Server geschlossen') process.exit(0) }) // Force close after 10 seconds setTimeout(() => { log.error('Erzwungenes Herunterfahren') process.exit(1) }, 10000) } process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) process.on('SIGINT', () => gracefulShutdown('SIGINT')) // Handle uncaught errors process.on('uncaughtException', (err) => { log.error('Uncaught Exception:', { error: err.message, stack: err.stack }) process.exit(1) }) process.on('unhandledRejection', (reason, promise) => { log.error('Unhandled Rejection:', { reason, promise }) }) // Start server server = app.listen(config.port, () => { console.log('') log.success(`Server gestartet auf Port ${config.port}`) log.info(`Frontend URL: ${config.frontendUrl}`) log.info(`Environment: ${config.nodeEnv}`) console.log('') console.log(` 🌐 API: http://localhost:${config.port}/api`) console.log(` 💚 Health: http://localhost:${config.port}/api/health`) console.log('') startCounterJobs() startAutoSortJob() }) export default app