- {connecting === 'outlook' ? (
-
- ) : (
-
- )}
-
-
Outlook
-
Microsoft 365
+
+
-
-
+
+ Or connect your inbox
+
+
+
+
+
+ {connecting === 'gmail' ? (
+
+ ) : (
+
+ )}
+
+
Gmail
+
Google Workspace
+
+
+
+
+
+ {connecting === 'outlook' ? (
+
+ ) : (
+
+ )}
+
+
Outlook
+
Microsoft 365
+
+
+
+
diff --git a/client/src/types/settings.ts b/client/src/types/settings.ts
index 8ce34b0..1159c0c 100644
--- a/client/src/types/settings.ts
+++ b/client/src/types/settings.ts
@@ -2,10 +2,60 @@
* TypeScript types for Settings and AI Control
*/
+export interface CleanupReadItems {
+ enabled: boolean
+ action: 'archive_read' | 'trash'
+ gracePeriodDays: number
+}
+
+export interface CleanupPromotions {
+ enabled: boolean
+ matchCategoriesOrLabels: string[]
+ action: 'archive_read' | 'trash'
+ deleteAfterDays: number
+}
+
+export interface CleanupSafety {
+ requireConfirmForDelete: boolean
+ dryRun?: boolean
+ maxDeletesPerRun?: number
+}
+
+export interface CleanupSettings {
+ enabled: boolean
+ readItems: CleanupReadItems
+ promotions: CleanupPromotions
+ safety: CleanupSafety
+}
+
+export interface CategoryAdvanced {
+ priority?: 'low' | 'medium' | 'high'
+ includeLabels?: string[]
+ excludeKeywords?: string[]
+}
+
+export interface CleanupStatus {
+ lastRun?: string
+ lastRunCounts?: {
+ readItems: number
+ promotions: number
+ }
+ preview?: Array<{
+ id: string
+ subject: string
+ from: string
+ date: string
+ reason: 'read' | 'promotion'
+ }>
+}
+
export interface AIControlSettings {
+ version?: number
enabledCategories: string[]
categoryActions: Record
autoDetectCompanies: boolean
+ cleanup?: CleanupSettings
+ categoryAdvanced?: Record
}
export interface CompanyLabel {
diff --git a/git-commit.bat b/git-commit.bat
new file mode 100644
index 0000000..0e429b6
--- /dev/null
+++ b/git-commit.bat
@@ -0,0 +1,16 @@
+@echo off
+REM Git Commit Script für Control Panel Redesign (Windows Batch)
+
+cd /d c:\Users\User\Documents\GitHub\ANDJJJJJJ
+
+REM Alle Änderungen stagen
+git add .
+
+REM Commit mit detaillierter Nachricht
+git commit -m "feat: Control Panel Redesign v2.0 & UI Improvements" -m "🎨 Control Panel komplettes Redesign (Version 2.0)" -m "- Card-basiertes Layout: Kategorien als interaktive Cards im Grid" -m "- Side Panel Integration: Click-to-Configure Pattern für Category Details" -m "- Moderne UX: Dashboard-artiges Design statt klassischer Settings-Seite" -m "" -m "🧹 Cleanup Tab Redesign" -m "- Große Toggle-Cards für Auto Cleanup Features" -m "- Neue Slider-Komponente für intuitive Tage-Auswahl" -m "- Preset Buttons (7/14/30 Tage) für Promotion Cleanup" -m "- Preview Section für betroffene E-Mails" -m "" -m "🏷️ Labels Tab Redesign" -m "- Professionelle Tabellenansicht mit Name, Status, Category, Actions" -m "- Side Panel Editor für Label-Erstellung/Bearbeitung" -m "- Responsive Table (Spalten werden auf Mobile ausgeblendet)" -m "- Import/Export Funktionalität" -m "" -m "📐 Layout Verbesserungen" -m "- Volle Breite: Dashboard und Settings nutzen gesamte verfügbare Breite" -m "- Responsive Navigation: Side Panels werden auf Mobile zu Fullscreen-Modals" -m "- Verbesserte Header mit humaneren Untertiteln" -m "" -m "🌙 Dark Mode Verbesserungen" -m "- Privacy & Security: Alle Info-Boxen haben Dark Mode Varianten" -m "- Input-Komponente: Dark Mode Hintergrund korrigiert" -m "- Slider-Komponente: Dark Mode Styles für Track und Thumb" -m "- Chevron Icons: Dark Mode Farben für Advanced Options" -m "- Konsistente Dark Mode Unterstützung in allen Komponenten" -m "" -m "✨ Neue Komponenten" -m "- client/src/components/ui/side-panel.tsx: Radix UI Dialog-basierte Side Panel" -m "- client/src/components/ui/slider.tsx: Range Input Slider mit Dark Mode" -m "" -m "📝 Geänderte Dateien" -m "- client/src/pages/Settings.tsx: Control Panel komplett neu strukturiert" -m "- client/src/components/PrivacySecurity.tsx: Dark Mode für alle Info-Boxen" -m "- client/src/components/ui/input.tsx: Dark Mode Hintergrund korrigiert" -m "- client/src/pages/Dashboard.tsx: Volle Breite Layout"
+
+REM Pushen
+git push
+
+echo ✅ Commit erfolgreich erstellt und gepusht!
+pause
diff --git a/git-commit.sh b/git-commit.sh
new file mode 100644
index 0000000..f660865
--- /dev/null
+++ b/git-commit.sh
@@ -0,0 +1,54 @@
+#!/bin/bash
+# Git Commit Script für Control Panel Redesign
+
+cd c:\Users\User\Documents\GitHub\ANDJJJJJJ
+
+# Alle Änderungen stagen
+git add .
+
+# Commit mit detaillierter Nachricht
+git commit -m "feat: Control Panel Redesign v2.0 & UI Improvements
+
+🎨 Control Panel komplettes Redesign (Version 2.0)
+- Card-basiertes Layout: Kategorien als interaktive Cards im Grid
+- Side Panel Integration: Click-to-Configure Pattern für Category Details
+- Moderne UX: Dashboard-artiges Design statt klassischer Settings-Seite
+
+🧹 Cleanup Tab Redesign
+- Große Toggle-Cards für Auto Cleanup Features
+- Neue Slider-Komponente für intuitive Tage-Auswahl
+- Preset Buttons (7/14/30 Tage) für Promotion Cleanup
+- Preview Section für betroffene E-Mails
+
+🏷️ Labels Tab Redesign
+- Professionelle Tabellenansicht mit Name, Status, Category, Actions
+- Side Panel Editor für Label-Erstellung/Bearbeitung
+- Responsive Table (Spalten werden auf Mobile ausgeblendet)
+- Import/Export Funktionalität
+
+📐 Layout Verbesserungen
+- Volle Breite: Dashboard und Settings nutzen gesamte verfügbare Breite
+- Responsive Navigation: Side Panels werden auf Mobile zu Fullscreen-Modals
+- Verbesserte Header mit humaneren Untertiteln
+
+🌙 Dark Mode Verbesserungen
+- Privacy & Security: Alle Info-Boxen haben Dark Mode Varianten
+- Input-Komponente: Dark Mode Hintergrund korrigiert
+- Slider-Komponente: Dark Mode Styles für Track und Thumb
+- Chevron Icons: Dark Mode Farben für Advanced Options
+- Konsistente Dark Mode Unterstützung in allen Komponenten
+
+✨ Neue Komponenten
+- client/src/components/ui/side-panel.tsx: Radix UI Dialog-basierte Side Panel
+- client/src/components/ui/slider.tsx: Range Input Slider mit Dark Mode
+
+📝 Geänderte Dateien
+- client/src/pages/Settings.tsx: Control Panel komplett neu strukturiert
+- client/src/components/PrivacySecurity.tsx: Dark Mode für alle Info-Boxen
+- client/src/components/ui/input.tsx: Dark Mode Hintergrund korrigiert
+- client/src/pages/Dashboard.tsx: Volle Breite Layout"
+
+# Pushen
+git push
+
+echo "✅ Commit erfolgreich erstellt und gepusht!"
diff --git a/run-git-commit.ps1 b/run-git-commit.ps1
new file mode 100644
index 0000000..2a8e06d
--- /dev/null
+++ b/run-git-commit.ps1
@@ -0,0 +1,77 @@
+# PowerShell Script zum Ausführen von Git-Befehlen
+$ErrorActionPreference = "Stop"
+
+# Git-Pfade zum Durchsuchen
+$gitPaths = @(
+ "git", # Falls im PATH
+ "C:\Program Files\Git\bin\git.exe",
+ "C:\Program Files (x86)\Git\bin\git.exe",
+ "$env:LOCALAPPDATA\Programs\Git\bin\git.exe",
+ "$env:ProgramFiles\Git\cmd\git.exe",
+ "$env:ProgramFiles(x86)\Git\cmd\git.exe"
+)
+
+$gitExe = $null
+foreach ($path in $gitPaths) {
+ try {
+ if ($path -eq "git") {
+ $gitExe = Get-Command git -ErrorAction SilentlyContinue
+ if ($gitExe) {
+ $gitExe = $gitExe.Source
+ break
+ }
+ } else {
+ if (Test-Path $path) {
+ $gitExe = $path
+ break
+ }
+ }
+ } catch {
+ continue
+ }
+}
+
+if (-not $gitExe) {
+ Write-Host "Git wurde nicht gefunden. Bitte installiere Git oder fuege es zum PATH hinzu." -ForegroundColor Red
+ Write-Host ""
+ Write-Host "Alternativ führe diese Befehle manuell in Git Bash aus:" -ForegroundColor Yellow
+ Write-Host "cd c:\Users\User\Documents\GitHub\ANDJJJJJJ" -ForegroundColor Cyan
+ Write-Host "git add ." -ForegroundColor Cyan
+ Write-Host "git commit -m `"feat: Control Panel Redesign v2.0 - Card-basiertes Layout, Side Panels, Dark Mode Fixes, Volle Breite Layout`"" -ForegroundColor Cyan
+ Write-Host "git push" -ForegroundColor Cyan
+ exit 1
+}
+
+Write-Host "Git gefunden: $gitExe" -ForegroundColor Green
+Write-Host ""
+
+# Zum Projektverzeichnis wechseln
+Set-Location "c:\Users\User\Documents\GitHub\ANDJJJJJJ"
+
+# Git-Befehle ausführen
+Write-Host "Staging aller Aenderungen..." -ForegroundColor Yellow
+& $gitExe add .
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "❌ Fehler beim Staging" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "Erstelle Commit..." -ForegroundColor Yellow
+& $gitExe commit -m "feat: Control Panel Redesign v2.0 - Card-basiertes Layout, Side Panels, Dark Mode Fixes, Volle Breite Layout"
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "❌ Fehler beim Commit" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host "Pushe Aenderungen..." -ForegroundColor Yellow
+& $gitExe push
+
+if ($LASTEXITCODE -ne 0) {
+ Write-Host "❌ Fehler beim Push" -ForegroundColor Red
+ exit 1
+}
+
+Write-Host ""
+Write-Host "Erfolgreich committed und gepusht!" -ForegroundColor Green
diff --git a/server/bootstrap-v2.mjs b/server/bootstrap-v2.mjs
index 5036497..c05e189 100644
--- a/server/bootstrap-v2.mjs
+++ b/server/bootstrap-v2.mjs
@@ -200,6 +200,58 @@ async function setupCollections() {
db.createStringAttribute(DB_ID, 'user_preferences', 'userId', 64, true));
await ensureAttribute('user_preferences', 'preferencesJson', () =>
db.createStringAttribute(DB_ID, 'user_preferences', 'preferencesJson', 16384, false));
+
+ // ==================== Onboarding State ====================
+ await ensureCollection('onboarding_state', 'Onboarding State', PERM_AUTHENTICATED);
+ await ensureAttribute('onboarding_state', 'userId', () =>
+ db.createStringAttribute(DB_ID, 'onboarding_state', 'userId', 64, true));
+ await ensureAttribute('onboarding_state', 'onboarding_step', () =>
+ db.createStringAttribute(DB_ID, 'onboarding_state', 'onboarding_step', 32, true));
+ await ensureAttribute('onboarding_state', 'completed_steps_json', () =>
+ db.createStringAttribute(DB_ID, 'onboarding_state', 'completed_steps_json', 1024, false));
+ await ensureAttribute('onboarding_state', 'first_value_seen_at', () =>
+ db.createDatetimeAttribute(DB_ID, 'onboarding_state', 'first_value_seen_at', false));
+ await ensureAttribute('onboarding_state', 'skipped_at', () =>
+ db.createDatetimeAttribute(DB_ID, 'onboarding_state', 'skipped_at', false));
+ await ensureAttribute('onboarding_state', 'last_updated', () =>
+ db.createDatetimeAttribute(DB_ID, 'onboarding_state', 'last_updated', false));
+
+ // ==================== Email Usage ====================
+ await ensureCollection('email_usage', 'Email Usage', PERM_AUTHENTICATED);
+ await ensureAttribute('email_usage', 'userId', () =>
+ db.createStringAttribute(DB_ID, 'email_usage', 'userId', 64, true));
+ await ensureAttribute('email_usage', 'month', () =>
+ db.createStringAttribute(DB_ID, 'email_usage', 'month', 16, true)); // "2026-01"
+ await ensureAttribute('email_usage', 'emailsProcessed', () =>
+ db.createIntegerAttribute(DB_ID, 'email_usage', 'emailsProcessed', true, 0));
+ await ensureAttribute('email_usage', 'lastReset', () =>
+ db.createDatetimeAttribute(DB_ID, 'email_usage', 'lastReset', false));
+
+ // ==================== Referrals ====================
+ await ensureCollection('referrals', 'Referrals', PERM_AUTHENTICATED);
+ await ensureAttribute('referrals', 'userId', () =>
+ db.createStringAttribute(DB_ID, 'referrals', 'userId', 64, true));
+ await ensureAttribute('referrals', 'referralCode', () =>
+ db.createStringAttribute(DB_ID, 'referrals', 'referralCode', 32, true));
+ await ensureAttribute('referrals', 'referredBy', () =>
+ db.createStringAttribute(DB_ID, 'referrals', 'referredBy', 64, false));
+ await ensureAttribute('referrals', 'referralCount', () =>
+ db.createIntegerAttribute(DB_ID, 'referrals', 'referralCount', true, 0));
+ await ensureAttribute('referrals', 'createdAt', () =>
+ db.createDatetimeAttribute(DB_ID, 'referrals', 'createdAt', false));
+
+ // ==================== Analytics Events ====================
+ await ensureCollection('analytics_events', 'Analytics Events', PERM_PUBLIC_READ);
+ await ensureAttribute('analytics_events', 'userId', () =>
+ db.createStringAttribute(DB_ID, 'analytics_events', 'userId', 64, false));
+ await ensureAttribute('analytics_events', 'eventType', () =>
+ db.createStringAttribute(DB_ID, 'analytics_events', 'eventType', 64, true));
+ await ensureAttribute('analytics_events', 'metadataJson', () =>
+ db.createStringAttribute(DB_ID, 'analytics_events', 'metadataJson', 4096, false));
+ await ensureAttribute('analytics_events', 'timestamp', () =>
+ db.createDatetimeAttribute(DB_ID, 'analytics_events', 'timestamp', false));
+ await ensureAttribute('analytics_events', 'sessionId', () =>
+ db.createStringAttribute(DB_ID, 'analytics_events', 'sessionId', 64, false));
}
async function main() {
diff --git a/server/config/index.mjs b/server/config/index.mjs
index 66415ce..76a1f16 100644
--- a/server/config/index.mjs
+++ b/server/config/index.mjs
@@ -68,6 +68,13 @@ export const config = {
origin: process.env.CORS_ORIGIN || process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
},
+
+ // Free Tier Limits
+ freeTier: {
+ emailsPerMonth: parseInt(process.env.FREE_TIER_EMAILS_PER_MONTH || '500', 10),
+ emailAccounts: 1,
+ autoSchedule: false, // manual only
+ },
}
/**
diff --git a/server/index.mjs b/server/index.mjs
index 3b14418..09bef98 100644
--- a/server/index.mjs
+++ b/server/index.mjs
@@ -121,6 +121,7 @@ app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => {
enabledCategories: preferences.enabledCategories || [],
categoryActions: preferences.categoryActions || {},
autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : true,
+ cleanup: preferences.cleanup || userPreferences.getDefaults().cleanup,
})
}))
@@ -129,13 +130,14 @@ app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => {
* Save AI Control settings
*/
app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => {
- const { userId, enabledCategories, categoryActions, autoDetectCompanies } = req.body
+ const { userId, enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body
if (!userId) throw new ValidationError('userId is required')
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')
diff --git a/server/routes/analytics.mjs b/server/routes/analytics.mjs
index eebb5c2..669c75f 100644
--- a/server/routes/analytics.mjs
+++ b/server/routes/analytics.mjs
@@ -4,11 +4,57 @@
*/
import express from 'express'
-import { asyncHandler } from '../middleware/errorHandler.mjs'
+import { asyncHandler, ValidationError } from '../middleware/errorHandler.mjs'
import { respond } from '../utils/response.mjs'
+import { db, Collections } from '../services/database.mjs'
+import { log } from '../middleware/logger.mjs'
const router = express.Router()
+// Whitelist of allowed event types
+const ALLOWED_EVENT_TYPES = [
+ 'page_view',
+ 'signup',
+ 'trial_start',
+ 'purchase',
+ 'email_connected',
+ 'onboarding_step',
+ 'provider_connected',
+ 'demo_used',
+ 'suggested_rules_generated',
+ 'rule_created',
+ 'rules_applied',
+ 'limit_reached',
+ 'upgrade_clicked',
+ 'referral_shared',
+ 'sort_completed',
+ 'account_deleted',
+]
+
+// Fields that should never be stored (PII)
+const PII_FIELDS = ['email', 'password', 'emailContent', 'emailBody', 'subject', 'from', 'to', 'snippet', 'content']
+
+function stripPII(metadata) {
+ if (!metadata || typeof metadata !== 'object') return {}
+
+ const cleaned = {}
+ for (const [key, value] of Object.entries(metadata)) {
+ if (PII_FIELDS.includes(key.toLowerCase())) {
+ continue // Skip PII fields
+ }
+ if (typeof value === 'string' && value.includes('@')) {
+ // Skip if looks like email
+ continue
+ }
+ if (typeof value === 'object' && value !== null) {
+ cleaned[key] = stripPII(value)
+ } else {
+ cleaned[key] = value
+ }
+ }
+ return cleaned
+}
+
/**
* POST /api/analytics/track
* Track analytics events (page views, conversions, etc.)
@@ -39,29 +85,45 @@ router.post('/track', asyncHandler(async (req, res) => {
timestamp,
page,
referrer,
+ sessionId,
} = req.body
- // Log analytics event (in production, send to analytics service)
+ // Validate event type
+ if (!type || !ALLOWED_EVENT_TYPES.includes(type)) {
+ throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`)
+ }
+
+ // Strip PII from metadata
+ const cleanedMetadata = stripPII(metadata || {})
+
+ // Prepare event data
+ const eventData = {
+ userId: userId || null,
+ eventType: type,
+ metadataJson: JSON.stringify(cleanedMetadata),
+ timestamp: timestamp || new Date().toISOString(),
+ sessionId: sessionId || null,
+ }
+
+ // Store in database
+ try {
+ await db.create(Collections.ANALYTICS_EVENTS, eventData)
+ log.info(`Analytics event tracked: ${type}`, { userId, sessionId })
+ } catch (err) {
+ log.warn('Failed to store analytics event', { error: err.message, type })
+ // Don't fail the request if analytics storage fails
+ }
+
+ // Log in development
if (process.env.NODE_ENV === 'development') {
console.log('📊 Analytics Event:', {
type,
userId,
- tracking,
- metadata,
- timestamp,
- page,
- referrer,
+ sessionId,
+ metadata: cleanedMetadata,
})
}
- // TODO: Store in database for analytics dashboard
- // For now, just log to console
- // In production, you might want to:
- // - Store in database
- // - Send to Google Analytics / Plausible / etc.
- // - Send to Mixpanel / Amplitude
- // - Log to external analytics service
-
// Return success (client doesn't need to wait)
respond.success(res, { received: true })
}))
diff --git a/server/routes/api.mjs b/server/routes/api.mjs
index 25cf4fc..bf8864b 100644
--- a/server/routes/api.mjs
+++ b/server/routes/api.mjs
@@ -7,9 +7,10 @@ import express from 'express'
import { asyncHandler, NotFoundError, ValidationError } from '../middleware/errorHandler.mjs'
import { validate, schemas, rules } from '../middleware/validate.mjs'
import { respond } from '../utils/response.mjs'
-import { products, questions, submissions, orders } from '../services/database.mjs'
+import { products, questions, submissions, orders, onboardingState, emailAccounts, emailStats, emailDigests, userPreferences, subscriptions, emailUsage, referrals, db, Collections, Query } from '../services/database.mjs'
import Stripe from 'stripe'
import { config } from '../config/index.mjs'
+import { log } from '../middleware/logger.mjs'
const router = express.Router()
const stripe = new Stripe(config.stripe.secretKey)
@@ -171,4 +172,232 @@ router.get('/config', (req, res) => {
})
})
+/**
+ * GET /api/onboarding/status
+ * Get current onboarding state
+ */
+router.get('/onboarding/status',
+ validate({
+ query: {
+ userId: [rules.required('userId')],
+ },
+ }),
+ asyncHandler(async (req, res) => {
+ const { userId } = req.query
+ const state = await onboardingState.getByUser(userId)
+ respond.success(res, state)
+ })
+)
+
+/**
+ * POST /api/onboarding/step
+ * Update onboarding step progress
+ */
+router.post('/onboarding/step',
+ validate({
+ body: {
+ userId: [rules.required('userId')],
+ step: [rules.required('step')],
+ completedSteps: [rules.isArray('completedSteps')],
+ },
+ }),
+ asyncHandler(async (req, res) => {
+ const { userId, step, completedSteps = [] } = req.body
+ await onboardingState.updateStep(userId, step, completedSteps)
+ respond.success(res, { step, completedSteps })
+ })
+)
+
+/**
+ * POST /api/onboarding/skip
+ * Skip onboarding
+ */
+router.post('/onboarding/skip',
+ validate({
+ body: {
+ userId: [rules.required('userId')],
+ },
+ }),
+ asyncHandler(async (req, res) => {
+ const { userId } = req.body
+ await onboardingState.skip(userId)
+ respond.success(res, { skipped: true })
+ })
+)
+
+/**
+ * POST /api/onboarding/resume
+ * Resume onboarding
+ */
+router.post('/onboarding/resume',
+ validate({
+ body: {
+ userId: [rules.required('userId')],
+ },
+ }),
+ asyncHandler(async (req, res) => {
+ const { userId } = req.body
+ await onboardingState.resume(userId)
+ const state = await onboardingState.getByUser(userId)
+ respond.success(res, state)
+ })
+)
+
+/**
+ * DELETE /api/account/delete
+ * Delete all user data and account
+ */
+router.delete('/account/delete',
+ validate({
+ body: {
+ userId: [rules.required('userId')],
+ },
+ }),
+ asyncHandler(async (req, res) => {
+ const { userId } = req.body
+
+ log.info(`Account deletion requested for user ${userId}`)
+
+ // Delete all user data
+ try {
+ // Delete email accounts
+ const accounts = await emailAccounts.getByUser(userId)
+ for (const account of accounts) {
+ try {
+ await db.delete(Collections.EMAIL_ACCOUNTS, account.$id)
+ } catch (err) {
+ log.warn(`Failed to delete account ${account.$id}`, { error: err.message })
+ }
+ }
+
+ // Delete stats
+ const stats = await emailStats.getByUser(userId)
+ if (stats) {
+ try {
+ await db.delete(Collections.EMAIL_STATS, stats.$id)
+ } catch (err) {
+ log.warn(`Failed to delete stats`, { error: err.message })
+ }
+ }
+
+ // Delete digests
+ const digests = await emailDigests.getByUser(userId)
+ for (const digest of digests) {
+ try {
+ await db.delete(Collections.EMAIL_DIGESTS, digest.$id)
+ } catch (err) {
+ log.warn(`Failed to delete digest ${digest.$id}`, { error: err.message })
+ }
+ }
+
+ // Delete preferences
+ const prefs = await userPreferences.getByUser(userId)
+ if (prefs) {
+ try {
+ await db.delete(Collections.USER_PREFERENCES, prefs.$id)
+ } catch (err) {
+ log.warn(`Failed to delete preferences`, { error: err.message })
+ }
+ }
+
+ // Delete subscription
+ const subscription = await subscriptions.getByUser(userId)
+ if (subscription && subscription.$id) {
+ try {
+ await db.delete(Collections.SUBSCRIPTIONS, subscription.$id)
+ } catch (err) {
+ log.warn(`Failed to delete subscription`, { error: err.message })
+ }
+ }
+
+ // Delete email usage
+ const usageRecords = await db.list(Collections.EMAIL_USAGE, [Query.equal('userId', userId)])
+ for (const usage of usageRecords) {
+ try {
+ await db.delete(Collections.EMAIL_USAGE, usage.$id)
+ } catch (err) {
+ log.warn(`Failed to delete usage record`, { error: err.message })
+ }
+ }
+
+ // Delete onboarding state
+ const onboarding = await onboardingState.getByUser(userId)
+ if (onboarding && onboarding.$id) {
+ try {
+ await db.delete(Collections.ONBOARDING_STATE, onboarding.$id)
+ } catch (err) {
+ log.warn(`Failed to delete onboarding state`, { error: err.message })
+ }
+ }
+
+ log.success(`Account deletion completed for user ${userId}`)
+ respond.success(res, { success: true, message: 'All data deleted successfully' })
+ } catch (err) {
+ log.error('Account deletion failed', { error: err.message, userId })
+ throw new ValidationError('Failed to delete account data')
+ }
+ })
+)
+
+/**
+ * GET /api/referrals/code
+ * Get or create referral code for user
+ */
+router.get('/referrals/code',
+ validate({
+ query: {
+ userId: [rules.required('userId')],
+ },
+ }),
+ asyncHandler(async (req, res) => {
+ const { userId } = req.query
+ const referral = await referrals.getOrCreateCode(userId)
+ respond.success(res, {
+ referralCode: referral.referralCode,
+ referralCount: referral.referralCount || 0,
+ })
+ })
+)
+
+/**
+ * POST /api/referrals/track
+ * Track a referral (when new user signs up with referral code)
+ */
+router.post('/referrals/track',
+ validate({
+ body: {
+ userId: [rules.required('userId')],
+ referralCode: [rules.required('referralCode')],
+ },
+ }),
+ asyncHandler(async (req, res) => {
+ const { userId, referralCode } = req.body
+
+ // Find referrer by code
+ const referrer = await referrals.getByCode(referralCode)
+ if (!referrer) {
+ throw new NotFoundError('Referral code')
+ }
+
+ // Don't allow self-referral
+ if (referrer.userId === userId) {
+ throw new ValidationError('Cannot refer yourself')
+ }
+
+ // Update referrer's count
+ await referrals.incrementCount(referrer.userId)
+
+ // Store referral relationship
+ await referrals.getOrCreateCode(userId)
+ const userReferral = await referrals.getOrCreateCode(userId)
+ await db.update(Collections.REFERRALS, userReferral.$id, {
+ referredBy: referrer.userId,
+ })
+
+ log.info(`Referral tracked: ${userId} referred by ${referrer.userId} (code: ${referralCode})`)
+
+ respond.success(res, { success: true })
+ })
+)
+
export default router
diff --git a/server/routes/email.mjs b/server/routes/email.mjs
index 2014412..804c0e2 100644
--- a/server/routes/email.mjs
+++ b/server/routes/email.mjs
@@ -8,7 +8,7 @@ import { asyncHandler, NotFoundError, AuthorizationError, ValidationError } from
import { validate, rules } from '../middleware/validate.mjs'
import { limiters } from '../middleware/rateLimit.mjs'
import { respond } from '../utils/response.mjs'
-import { emailAccounts, emailStats, emailDigests, userPreferences } from '../services/database.mjs'
+import { emailAccounts, emailStats, emailDigests, userPreferences, emailUsage, subscriptions } from '../services/database.mjs'
import { config, features } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs'
@@ -270,7 +270,34 @@ router.post('/sort',
}),
asyncHandler(async (req, res) => {
const { userId, accountId, maxEmails = 500, processAll = true } = req.body
- const effectiveMax = Math.min(maxEmails, 2000) // Cap at 2000 emails
+
+ // Check subscription status and free tier limits
+ const subscription = await subscriptions.getByUser(userId)
+ const isFreeTier = subscription?.isFreeTier || false
+
+ // Check free tier limit
+ if (isFreeTier) {
+ const usage = await emailUsage.getUsage(userId)
+ const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth
+
+ if (usage.emailsProcessed >= limit) {
+ return respond.error(res, {
+ code: 'LIMIT_REACHED',
+ message: `You've processed ${limit} emails this month. Upgrade for unlimited sorting.`,
+ limit,
+ used: usage.emailsProcessed,
+ }, 403)
+ }
+ }
+
+ // Check if this is first run (no stats exist)
+ const existingStats = await emailStats.getByUser(userId)
+ const isFirstRun = !existingStats || existingStats.totalSorted === 0
+
+ // For first run, limit to 50 emails for speed
+ const effectiveMax = isFirstRun
+ ? Math.min(maxEmails, 50)
+ : Math.min(maxEmails, 2000) // Cap at 2000 emails
// Get account
const account = await emailAccounts.get(accountId)
@@ -287,6 +314,7 @@ router.post('/sort',
const sorter = await getAISorter()
let sortedCount = 0
const results = { byCategory: {} }
+ let emailSamples = [] // For suggested rules generation
// ═══════════════════════════════════════════════════════════════════════
// DEMO MODE - Sorting with simulated emails
@@ -304,10 +332,20 @@ router.post('/sort',
// Real AI sorting with demo data
const classified = await sorter.batchCategorize(emailsToSort, preferences)
- for (const { classification } of classified) {
+ for (const { email, classification } of classified) {
const category = classification.category
sortedCount++
results.byCategory[category] = (results.byCategory[category] || 0) + 1
+
+ // Collect samples for suggested rules (first run only, max 50)
+ if (isFirstRun && emailSamples.length < 50) {
+ emailSamples.push({
+ from: email.from,
+ subject: email.subject,
+ snippet: email.snippet,
+ category,
+ })
+ }
}
log.success(`AI sorting completed: ${sortedCount} demo emails`)
@@ -351,6 +389,16 @@ router.post('/sort',
sortedCount++
results.byCategory[category] = (results.byCategory[category] || 0) + 1
+
+ // Collect samples for suggested rules (first run only, max 50)
+ if (isFirstRun && emailSamples.length < 50) {
+ emailSamples.push({
+ from: email.from,
+ subject: email.subject,
+ snippet: email.snippet || '',
+ category,
+ })
+ }
}
log.success(`Rule-based sorting completed: ${sortedCount} demo emails`)
@@ -512,6 +560,16 @@ router.post('/sort',
category,
companyLabel,
})
+
+ // Collect samples for suggested rules (first run only, max 50)
+ if (isFirstRun && emailSamples.length < 50) {
+ emailSamples.push({
+ from: emailData.from,
+ subject: emailData.subject,
+ snippet: emailData.snippet,
+ category,
+ })
+ }
}
// Apply labels/categories and actions
@@ -740,8 +798,13 @@ router.post('/sort',
// Update last sync
await emailAccounts.updateLastSync(accountId)
+ // Update email usage (for free tier tracking)
+ if (isFreeTier) {
+ await emailUsage.increment(userId, sortedCount)
+ }
+
// Update stats
- const timeSaved = Math.round(sortedCount * 0.25) // 15 seconds per email
+ const timeSaved = Math.round(sortedCount * 0.1) // 6 seconds per email = 0.1 minutes
await emailStats.increment(userId, {
total: sortedCount,
today: sortedCount,
@@ -810,6 +873,17 @@ router.post('/sort',
log.success(`Sorting completed: ${sortedCount} emails for ${account.email}`)
+ // Generate suggested rules for first run
+ let suggestedRules = []
+ if (isFirstRun && emailSamples.length > 0 && features.ai()) {
+ try {
+ suggestedRules = await sorter.generateSuggestedRules(userId, emailSamples)
+ log.info(`Generated ${suggestedRules.length} suggested rules for first run`)
+ } catch (err) {
+ log.warn('Failed to generate suggested rules', { error: err.message })
+ }
+ }
+
respond.success(res, {
sorted: sortedCount,
inboxCleared,
@@ -818,6 +892,8 @@ router.post('/sort',
minutes: timeSaved,
formatted: timeSaved > 0 ? `${timeSaved} minutes` : '< 1 minute',
},
+ isFirstRun,
+ suggestedRules: suggestedRules.length > 0 ? suggestedRules : undefined,
highlights,
suggestions,
provider: account.provider,
@@ -1083,4 +1159,237 @@ router.post('/webhook/outlook', asyncHandler(async (req, res) => {
}
}))
+/**
+ * POST /api/email/cleanup
+ * Run auto-cleanup for read items and promotions
+ * Can be called manually or by cron job
+ */
+router.post('/cleanup', asyncHandler(async (req, res) => {
+ const { userId } = req.body // Optional: only process this user, otherwise all users
+
+ log.info('Cleanup job started', { userId: userId || 'all' })
+
+ const results = {
+ usersProcessed: 0,
+ emailsProcessed: {
+ readItems: 0,
+ promotions: 0,
+ },
+ errors: [],
+ }
+
+ try {
+ // Get all users with cleanup enabled
+ let usersToProcess = []
+
+ if (userId) {
+ // Single user mode
+ const prefs = await userPreferences.getByUser(userId)
+ if (prefs?.preferences?.cleanup?.enabled) {
+ usersToProcess = [{ userId, preferences: prefs.preferences }]
+ }
+ } else {
+ // All users mode - get all user preferences
+ // Note: This is a simplified approach. In production, you might want to add an index
+ // or query optimization for users with cleanup.enabled = true
+ const allPrefs = await emailAccounts.getByUser('*') // This won't work, need different approach
+ // For now, we'll process users individually when they have accounts
+ // TODO: Add efficient query for users with cleanup enabled
+ log.warn('Processing all users not yet implemented efficiently. Use userId parameter for single user cleanup.')
+ }
+
+ // If userId provided, process that user
+ if (userId) {
+ const prefs = await userPreferences.getByUser(userId)
+ if (!prefs?.preferences?.cleanup?.enabled) {
+ return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
+ }
+
+ const accounts = await emailAccounts.getByUser(userId)
+ if (!accounts || accounts.length === 0) {
+ return respond.success(res, { ...results, message: 'No email accounts found' })
+ }
+
+ for (const account of accounts) {
+ if (!account.isActive || !account.accessToken) continue
+
+ try {
+ const cleanup = prefs.preferences.cleanup
+
+ // Read Items Cleanup
+ if (cleanup.readItems?.enabled) {
+ const readItemsCount = await processReadItemsCleanup(
+ account,
+ cleanup.readItems.action,
+ cleanup.readItems.gracePeriodDays
+ )
+ results.emailsProcessed.readItems += readItemsCount
+ }
+
+ // Promotion Cleanup
+ if (cleanup.promotions?.enabled) {
+ const promotionsCount = await processPromotionsCleanup(
+ account,
+ cleanup.promotions.action,
+ cleanup.promotions.deleteAfterDays,
+ cleanup.promotions.matchCategoriesOrLabels || []
+ )
+ results.emailsProcessed.promotions += promotionsCount
+ }
+
+ results.usersProcessed = 1
+ } catch (error) {
+ log.error(`Cleanup failed for account ${account.email}`, { error: error.message })
+ results.errors.push({ userId, accountId: account.id, error: error.message })
+ }
+ }
+ }
+
+ log.success('Cleanup job completed', results)
+ respond.success(res, results, 'Cleanup completed')
+ } catch (error) {
+ log.error('Cleanup job failed', { error: error.message })
+ throw error
+ }
+}))
+
+/**
+ * Process read items cleanup for an account
+ */
+async function processReadItemsCleanup(account, action, gracePeriodDays) {
+ const cutoffDate = new Date()
+ cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays)
+
+ let processedCount = 0
+
+ try {
+ if (account.provider === 'gmail') {
+ const gmail = await getGmailService(account.accessToken, account.refreshToken)
+
+ // Find read emails older than grace period
+ // Query: -is:unread AND before:YYYY/MM/DD
+ const query = `-is:unread before:${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
+
+ const response = await gmail.gmail.users.messages.list({
+ userId: 'me',
+ q: query,
+ maxResults: 500, // Limit to avoid rate limits
+ })
+
+ const messages = response.data.messages || []
+
+ for (const message of messages) {
+ try {
+ if (action === 'archive_read') {
+ await gmail.archiveEmail(message.id)
+ await gmail.markAsRead(message.id)
+ } else if (action === 'trash') {
+ await gmail.trashEmail(message.id)
+ }
+ processedCount++
+ } catch (err) {
+ log.error(`Failed to process message ${message.id}`, { error: err.message })
+ }
+ }
+ } else if (account.provider === 'outlook') {
+ const outlook = await getOutlookService(account.accessToken)
+
+ // Find read emails older than grace period
+ const filter = `isRead eq true and receivedDateTime lt ${cutoffDate.toISOString()}`
+ const messages = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=500`)
+
+ for (const message of messages.value || []) {
+ try {
+ if (action === 'archive_read') {
+ await outlook.archiveEmail(message.id)
+ await outlook.markAsRead(message.id)
+ } else if (action === 'trash') {
+ await outlook.deleteEmail(message.id) // Outlook uses deleteEmail for trash
+ }
+ processedCount++
+ } catch (err) {
+ log.error(`Failed to process message ${message.id}`, { error: err.message })
+ }
+ }
+ }
+ } catch (error) {
+ log.error(`Read items cleanup failed for ${account.email}`, { error: error.message })
+ throw error
+ }
+
+ return processedCount
+}
+
+/**
+ * Process promotions cleanup for an account
+ */
+async function processPromotionsCleanup(account, action, deleteAfterDays, matchCategories) {
+ const cutoffDate = new Date()
+ cutoffDate.setDate(cutoffDate.getDate() - deleteAfterDays)
+
+ let processedCount = 0
+
+ try {
+ if (account.provider === 'gmail') {
+ const gmail = await getGmailService(account.accessToken, account.refreshToken)
+
+ // Find emails with matching categories/labels older than deleteAfterDays
+ // Look for emails with EmailSorter labels matching the categories
+ const labelQueries = matchCategories.map(cat => `label:EmailSorter/${cat}`).join(' OR ')
+ const query = `(${labelQueries}) before:${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
+
+ const response = await gmail.gmail.users.messages.list({
+ userId: 'me',
+ q: query,
+ maxResults: 500,
+ })
+
+ const messages = response.data.messages || []
+
+ for (const message of messages) {
+ try {
+ if (action === 'archive_read') {
+ await gmail.archiveEmail(message.id)
+ await gmail.markAsRead(message.id)
+ } else if (action === 'trash') {
+ await gmail.trashEmail(message.id)
+ }
+ processedCount++
+ } catch (err) {
+ log.error(`Failed to process promotion message ${message.id}`, { error: err.message })
+ }
+ }
+ } else if (account.provider === 'outlook') {
+ const outlook = await getOutlookService(account.accessToken)
+
+ // For Outlook, we'd need to check categories or use a different approach
+ // This is a simplified version - in production, you might store category info
+ const filter = `receivedDateTime lt ${cutoffDate.toISOString()}`
+ const messages = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=500`)
+
+ // Filter by category if available (would need to be stored during sorting)
+ for (const message of messages.value || []) {
+ // TODO: Check if message category matches matchCategories
+ // This requires storing category info during sorting
+ try {
+ if (action === 'archive_read') {
+ await outlook.archiveEmail(message.id)
+ await outlook.markAsRead(message.id)
+ } else if (action === 'trash') {
+ await outlook.deleteEmail(message.id) // Outlook uses deleteEmail for trash
+ }
+ processedCount++
+ } catch (err) {
+ log.error(`Failed to process promotion message ${message.id}`, { error: err.message })
+ }
+ }
+ }
+ } catch (error) {
+ log.error(`Promotions cleanup failed for ${account.email}`, { error: error.message })
+ throw error
+ }
+
+ return processedCount
+}
+
export default router
diff --git a/server/services/ai-sorter.mjs b/server/services/ai-sorter.mjs
index 81807e4..b84ad06 100644
--- a/server/services/ai-sorter.mjs
+++ b/server/services/ai-sorter.mjs
@@ -234,6 +234,121 @@ export class AISorterService {
return null
}
+ /**
+ * Generate suggested rules based on email patterns
+ * Analyzes email samples to detect patterns and suggest rules
+ */
+ async generateSuggestedRules(userId, emailSamples) {
+ if (!emailSamples || emailSamples.length === 0) {
+ return []
+ }
+
+ const suggestions = []
+ const senderCounts = {}
+ const domainCounts = {}
+ const subjectPatterns = {}
+ const categoryPatterns = {}
+
+ // Analyze patterns
+ for (const email of emailSamples) {
+ const from = email.from?.toLowerCase() || ''
+ const subject = email.subject?.toLowerCase() || ''
+
+ // Extract domain
+ const emailMatch = from.match(/@([^\s>]+)/)
+ if (emailMatch) {
+ const domain = emailMatch[1].toLowerCase()
+ domainCounts[domain] = (domainCounts[domain] || 0) + 1
+ }
+
+ // Count senders
+ const senderEmail = from.split('<')[1]?.split('>')[0] || from
+ senderCounts[senderEmail] = (senderCounts[senderEmail] || 0) + 1
+
+ // Detect category patterns
+ const category = email.category || 'review'
+ categoryPatterns[category] = (categoryPatterns[category] || 0) + 1
+ }
+
+ const totalEmails = emailSamples.length
+ const threshold = Math.max(3, Math.ceil(totalEmails * 0.1)) // At least 3 emails or 10% of total
+
+ // Suggest VIP senders (frequent senders)
+ const frequentSenders = Object.entries(senderCounts)
+ .filter(([_, count]) => count >= threshold)
+ .sort(([_, a], [__, b]) => b - a)
+ .slice(0, 3)
+
+ for (const [sender, count] of frequentSenders) {
+ suggestions.push({
+ type: 'vip_sender',
+ name: `Mark ${sender.split('@')[0]} as VIP`,
+ description: `${count} emails from this sender`,
+ confidence: Math.min(0.9, count / totalEmails),
+ action: {
+ type: 'add_vip',
+ email: sender,
+ },
+ })
+ }
+
+ // Suggest company labels (frequent domains)
+ const frequentDomains = Object.entries(domainCounts)
+ .filter(([domain, count]) => count >= threshold && !KNOWN_COMPANIES[domain])
+ .sort(([_, a], [__, b]) => b - a)
+ .slice(0, 3)
+
+ for (const [domain, count] of frequentDomains) {
+ const companyName = domain.split('.')[0].charAt(0).toUpperCase() + domain.split('.')[0].slice(1)
+ suggestions.push({
+ type: 'company_label',
+ name: `Label ${companyName} emails`,
+ description: `${count} emails from ${domain}`,
+ confidence: Math.min(0.85, count / totalEmails),
+ action: {
+ type: 'add_company_label',
+ name: companyName,
+ condition: `from:${domain}`,
+ category: 'promotions', // Default, user can change
+ },
+ })
+ }
+
+ // Suggest category-specific rules based on patterns
+ if (categoryPatterns.newsletters >= threshold) {
+ suggestions.push({
+ type: 'category_rule',
+ name: 'Archive newsletters automatically',
+ description: `${categoryPatterns.newsletters} newsletter emails detected`,
+ confidence: 0.8,
+ action: {
+ type: 'enable_category',
+ category: 'newsletters',
+ action: 'archive_read',
+ },
+ })
+ }
+
+ if (categoryPatterns.promotions >= threshold) {
+ suggestions.push({
+ type: 'category_rule',
+ name: 'Archive promotions automatically',
+ description: `${categoryPatterns.promotions} promotion emails detected`,
+ confidence: 0.75,
+ action: {
+ type: 'enable_category',
+ category: 'promotions',
+ action: 'archive_read',
+ },
+ })
+ }
+
+ // Sort by confidence and return top 5
+ return suggestions
+ .sort((a, b) => b.confidence - a.confidence)
+ .slice(0, 5)
+ }
+
/**
* Check if email matches a company label condition
*/
diff --git a/server/services/database.mjs b/server/services/database.mjs
index 02014d5..3e936ea 100644
--- a/server/services/database.mjs
+++ b/server/services/database.mjs
@@ -30,6 +30,10 @@ export const Collections = {
EMAIL_DIGESTS: 'email_digests',
SUBSCRIPTIONS: 'subscriptions',
USER_PREFERENCES: 'user_preferences',
+ ONBOARDING_STATE: 'onboarding_state',
+ EMAIL_USAGE: 'email_usage',
+ REFERRALS: 'referrals',
+ ANALYTICS_EVENTS: 'analytics_events',
}
/**
@@ -251,12 +255,86 @@ export const emailStats = {
},
}
+/**
+ * Email usage operations
+ */
+export const emailUsage = {
+ async getCurrentMonth(userId) {
+ const month = new Date().toISOString().slice(0, 7) // "2026-01"
+ return db.findOne(Collections.EMAIL_USAGE, [
+ Query.equal('userId', userId),
+ Query.equal('month', month),
+ ])
+ },
+
+ async increment(userId, count) {
+ const month = new Date().toISOString().slice(0, 7)
+ const existing = await this.getCurrentMonth(userId)
+
+ if (existing) {
+ return db.update(Collections.EMAIL_USAGE, existing.$id, {
+ emailsProcessed: (existing.emailsProcessed || 0) + count,
+ lastReset: new Date().toISOString(),
+ })
+ }
+
+ return db.create(Collections.EMAIL_USAGE, {
+ userId,
+ month,
+ emailsProcessed: count,
+ lastReset: new Date().toISOString(),
+ })
+ },
+
+ async getUsage(userId) {
+ const usage = await this.getCurrentMonth(userId)
+ return {
+ emailsProcessed: usage?.emailsProcessed || 0,
+ month: new Date().toISOString().slice(0, 7),
+ }
+ },
+}
+
/**
* Subscriptions operations
*/
export const subscriptions = {
async getByUser(userId) {
- return db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
+ const subscription = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
+
+ // If no subscription, user is on free tier
+ if (!subscription) {
+ const usage = await emailUsage.getUsage(userId)
+ return {
+ plan: 'free',
+ status: 'active',
+ isFreeTier: true,
+ emailsUsedThisMonth: usage.emailsProcessed,
+ emailsLimit: 500, // From config
+ }
+ }
+
+ // Check if subscription is active
+ const isActive = subscription.status === 'active'
+ const isFreeTier = !isActive || subscription.plan === 'free'
+
+ // Get usage for free tier users
+ let emailsUsedThisMonth = 0
+ let emailsLimit = -1 // Unlimited for paid
+
+ if (isFreeTier) {
+ const usage = await emailUsage.getUsage(userId)
+ emailsUsedThisMonth = usage.emailsProcessed
+ emailsLimit = 500 // From config
+ }
+
+ return {
+ ...subscription,
+ plan: subscription.plan || 'free',
+ isFreeTier,
+ emailsUsedThisMonth,
+ emailsLimit,
+ }
},
async getByStripeId(stripeSubscriptionId) {
@@ -296,6 +374,27 @@ export const userPreferences = {
categoryActions: {},
companyLabels: [],
autoDetectCompanies: true,
+ version: 1,
+ categoryAdvanced: {},
+ cleanup: {
+ enabled: false,
+ readItems: {
+ enabled: false,
+ action: 'archive_read',
+ gracePeriodDays: 7,
+ },
+ promotions: {
+ enabled: false,
+ matchCategoriesOrLabels: ['promotions', 'newsletters'],
+ action: 'archive_read',
+ deleteAfterDays: 30,
+ },
+ safety: {
+ requireConfirmForDelete: true,
+ dryRun: false,
+ maxDeletesPerRun: 100,
+ },
+ },
}
},
@@ -347,6 +446,170 @@ export const userPreferences = {
},
}
+/**
+ * Onboarding state operations
+ */
+export const onboardingState = {
+ async getByUser(userId) {
+ const state = await db.findOne(Collections.ONBOARDING_STATE, [
+ Query.equal('userId', userId),
+ ])
+ if (state?.completed_steps_json) {
+ return {
+ ...state,
+ completedSteps: JSON.parse(state.completed_steps_json),
+ }
+ }
+ return {
+ ...state,
+ completedSteps: [],
+ onboarding_step: state?.onboarding_step || 'not_started',
+ }
+ },
+
+ async updateStep(userId, step, completedSteps = []) {
+ const existing = await db.findOne(Collections.ONBOARDING_STATE, [
+ Query.equal('userId', userId),
+ ])
+
+ const data = {
+ onboarding_step: step,
+ completed_steps_json: JSON.stringify(completedSteps),
+ last_updated: new Date().toISOString(),
+ }
+
+ if (existing) {
+ return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
+ }
+ return db.create(Collections.ONBOARDING_STATE, { userId, ...data })
+ },
+
+ async markValueSeen(userId) {
+ const existing = await db.findOne(Collections.ONBOARDING_STATE, [
+ Query.equal('userId', userId),
+ ])
+
+ const data = {
+ first_value_seen_at: new Date().toISOString(),
+ last_updated: new Date().toISOString(),
+ }
+
+ if (existing) {
+ return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
+ }
+ return db.create(Collections.ONBOARDING_STATE, {
+ userId,
+ onboarding_step: 'see_results',
+ completed_steps_json: JSON.stringify(['connect', 'first_rule', 'see_results']),
+ ...data,
+ })
+ },
+
+ async skip(userId) {
+ const existing = await db.findOne(Collections.ONBOARDING_STATE, [
+ Query.equal('userId', userId),
+ ])
+
+ const data = {
+ skipped_at: new Date().toISOString(),
+ last_updated: new Date().toISOString(),
+ }
+
+ if (existing) {
+ return db.update(Collections.ONBOARDING_STATE, existing.$id, data)
+ }
+ return db.create(Collections.ONBOARDING_STATE, {
+ userId,
+ onboarding_step: 'not_started',
+ completed_steps_json: JSON.stringify([]),
+ ...data,
+ })
+ },
+
+ async resume(userId) {
+ const existing = await db.findOne(Collections.ONBOARDING_STATE, [
+ Query.equal('userId', userId),
+ ])
+
+ if (existing) {
+ return db.update(Collections.ONBOARDING_STATE, existing.$id, {
+ skipped_at: null,
+ last_updated: new Date().toISOString(),
+ })
+ }
+ // If no state exists, create initial state
+ return db.create(Collections.ONBOARDING_STATE, {
+ userId,
+ onboarding_step: 'connect',
+ completed_steps_json: JSON.stringify([]),
+ last_updated: new Date().toISOString(),
+ })
+ },
+}
+
+/**
+ * Referrals operations
+ */
+export const referrals = {
+ async getOrCreateCode(userId) {
+ const existing = await db.findOne(Collections.REFERRALS, [
+ Query.equal('userId', userId),
+ ])
+
+ if (existing) {
+ return existing
+ }
+
+ // Generate unique code: USER-ABC123
+ const randomPart = Math.random().toString(36).substring(2, 8).toUpperCase()
+ const code = `USER-${randomPart}`
+
+ // Ensure uniqueness
+ let uniqueCode = code
+ let attempts = 0
+ while (attempts < 10) {
+ const existingCode = await db.findOne(Collections.REFERRALS, [
+ Query.equal('referralCode', uniqueCode),
+ ])
+ if (!existingCode) break
+ uniqueCode = `USER-${Math.random().toString(36).substring(2, 8).toUpperCase()}`
+ attempts++
+ }
+
+ return db.create(Collections.REFERRALS, {
+ userId,
+ referralCode: uniqueCode,
+ referralCount: 0,
+ createdAt: new Date().toISOString(),
+ })
+ },
+
+ async getByCode(code) {
+ return db.findOne(Collections.REFERRALS, [
+ Query.equal('referralCode', code),
+ ])
+ },
+
+ async incrementCount(userId) {
+ const referral = await db.findOne(Collections.REFERRALS, [
+ Query.equal('userId', userId),
+ ])
+
+ if (referral) {
+ return db.update(Collections.REFERRALS, referral.$id, {
+ referralCount: (referral.referralCount || 0) + 1,
+ })
+ }
+ return null
+ },
+
+ async getReferrals(userId) {
+ return db.list(Collections.REFERRALS, [
+ Query.equal('referredBy', userId),
+ ])
+ },
+}
+
/**
* Orders operations
*/
@@ -450,6 +713,8 @@ export const emailDigests = {
},
}
+export { Query }
+
export default {
db,
products,
diff --git a/server/utils/response.mjs b/server/utils/response.mjs
index fc3ace9..0ec1403 100644
--- a/server/utils/response.mjs
+++ b/server/utils/response.mjs
@@ -64,6 +64,16 @@ export function redirect(res, url, statusCode = 302) {
return res.redirect(statusCode, url)
}
+/**
+ * Error response
+ */
+export function error(res, errorData, statusCode = 400) {
+ return res.status(statusCode).json({
+ success: false,
+ error: errorData,
+ })
+}
+
/**
* Response helpers object
*/
@@ -73,6 +83,7 @@ export const respond = {
noContent,
paginated,
redirect,
+ error,
}
export default respond
diff --git a/setup-production.ps1 b/setup-production.ps1
new file mode 100644
index 0000000..d51e09f
--- /dev/null
+++ b/setup-production.ps1
@@ -0,0 +1,142 @@
+# EmailSorter Production Setup Script
+# Dieses Script hilft beim Setup für Production
+
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "EmailSorter Production Setup" -ForegroundColor Cyan
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host ""
+
+# Prüfe ob Node.js installiert ist
+Write-Host "[1/5] Prüfe Node.js Installation..." -ForegroundColor Yellow
+try {
+ $nodeVersion = node --version
+ Write-Host "✓ Node.js gefunden: $nodeVersion" -ForegroundColor Green
+} catch {
+ Write-Host "✗ Node.js ist nicht installiert!" -ForegroundColor Red
+ Write-Host " Bitte installiere Node.js von https://nodejs.org/" -ForegroundColor Yellow
+ exit 1
+}
+
+# Prüfe ob PM2 installiert ist
+Write-Host ""
+Write-Host "[2/5] Prüfe PM2 Installation..." -ForegroundColor Yellow
+try {
+ $pm2Version = pm2 --version
+ Write-Host "✓ PM2 gefunden: $pm2Version" -ForegroundColor Green
+} catch {
+ Write-Host "✗ PM2 ist nicht installiert" -ForegroundColor Yellow
+ Write-Host " Installiere PM2..." -ForegroundColor Yellow
+ npm install -g pm2
+ Write-Host "✓ PM2 installiert" -ForegroundColor Green
+}
+
+# Backend Setup
+Write-Host ""
+Write-Host "[3/5] Backend Setup..." -ForegroundColor Yellow
+$serverPath = Join-Path $PSScriptRoot "server"
+if (Test-Path $serverPath) {
+ Set-Location $serverPath
+
+ # Prüfe .env Datei
+ if (-not (Test-Path ".env")) {
+ Write-Host "⚠ .env Datei nicht gefunden!" -ForegroundColor Yellow
+ Write-Host " Erstelle .env aus env.example..." -ForegroundColor Yellow
+ Copy-Item "env.example" ".env"
+ Write-Host " ⚠ WICHTIG: Bearbeite server/.env und setze die Production-Werte:" -ForegroundColor Red
+ Write-Host " - NODE_ENV=production" -ForegroundColor Yellow
+ Write-Host " - FRONTEND_URL=https://emailsorter.webklar.com" -ForegroundColor Yellow
+ Write-Host " - CORS_ORIGIN=https://emailsorter.webklar.com" -ForegroundColor Yellow
+ Write-Host " - BASE_URL=https://api.emailsorter.webklar.com (oder deine API URL)" -ForegroundColor Yellow
+ Write-Host ""
+ Write-Host " Drücke Enter, wenn du die .env Datei bearbeitet hast..." -ForegroundColor Cyan
+ Read-Host
+ }
+
+ # Installiere Dependencies
+ Write-Host " Installiere Backend Dependencies..." -ForegroundColor Yellow
+ npm install
+ Write-Host "✓ Backend Dependencies installiert" -ForegroundColor Green
+
+ # Prüfe ob Server bereits läuft
+ $pm2List = pm2 list 2>&1
+ if ($pm2List -match "emailsorter-api") {
+ Write-Host " Server läuft bereits. Neustart..." -ForegroundColor Yellow
+ pm2 restart emailsorter-api
+ } else {
+ Write-Host " Starte Backend Server mit PM2..." -ForegroundColor Yellow
+ pm2 start index.mjs --name emailsorter-api
+ pm2 save
+ }
+
+ Write-Host "✓ Backend Server gestartet" -ForegroundColor Green
+ Write-Host " Status: pm2 status" -ForegroundColor Cyan
+ Write-Host " Logs: pm2 logs emailsorter-api" -ForegroundColor Cyan
+} else {
+ Write-Host "✗ Server Verzeichnis nicht gefunden!" -ForegroundColor Red
+}
+
+# Frontend Build
+Write-Host ""
+Write-Host "[4/5] Frontend Build..." -ForegroundColor Yellow
+$clientPath = Join-Path $PSScriptRoot "client"
+if (Test-Path $clientPath) {
+ Set-Location $clientPath
+
+ # Prüfe .env.production
+ if (-not (Test-Path ".env.production")) {
+ Write-Host " Erstelle .env.production..." -ForegroundColor Yellow
+ $envContent = @"
+VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
+VITE_APPWRITE_PROJECT_ID=DEINE_PROJEKT_ID
+VITE_API_URL=https://api.emailsorter.webklar.com
+"@
+ Set-Content -Path ".env.production" -Value $envContent
+ Write-Host " ⚠ WICHTIG: Bearbeite client/.env.production und setze die richtigen Werte!" -ForegroundColor Red
+ Write-Host ""
+ Write-Host " Drücke Enter, wenn du die .env.production Datei bearbeitet hast..." -ForegroundColor Cyan
+ Read-Host
+ }
+
+ # Installiere Dependencies
+ Write-Host " Installiere Frontend Dependencies..." -ForegroundColor Yellow
+ npm install
+ Write-Host "✓ Frontend Dependencies installiert" -ForegroundColor Green
+
+ # Build
+ Write-Host " Baue Frontend für Production..." -ForegroundColor Yellow
+ npm run build
+ Write-Host "✓ Frontend Build abgeschlossen" -ForegroundColor Green
+ Write-Host " Build-Ordner: client/dist" -ForegroundColor Cyan
+ Write-Host " ⚠ WICHTIG: Deploye den client/dist Ordner zu deinem Web-Server!" -ForegroundColor Yellow
+} else {
+ Write-Host "✗ Client Verzeichnis nicht gefunden!" -ForegroundColor Red
+}
+
+# Zusammenfassung
+Write-Host ""
+Write-Host "[5/5] Zusammenfassung" -ForegroundColor Yellow
+Write-Host "========================================" -ForegroundColor Cyan
+Write-Host "✓ Backend Setup abgeschlossen" -ForegroundColor Green
+Write-Host "✓ Frontend Build abgeschlossen" -ForegroundColor Green
+Write-Host ""
+Write-Host "⚠ NOCH ZU TUN:" -ForegroundColor Red
+Write-Host ""
+Write-Host "1. APPWRITE CORS KONFIGURIEREN:" -ForegroundColor Yellow
+Write-Host " - Gehe zu https://appwrite.webklar.com" -ForegroundColor White
+Write-Host " - Öffne dein Projekt" -ForegroundColor White
+Write-Host " - Settings → Platforms → Add Platform" -ForegroundColor White
+Write-Host " - Hostname: emailsorter.webklar.com" -ForegroundColor White
+Write-Host " - Origin: https://emailsorter.webklar.com" -ForegroundColor White
+Write-Host ""
+Write-Host "2. FRONTEND DEPLOYEN:" -ForegroundColor Yellow
+Write-Host " - Kopiere client/dist zu deinem Web-Server" -ForegroundColor White
+Write-Host " - Stelle sicher, dass die Domain richtig konfiguriert ist" -ForegroundColor White
+Write-Host ""
+Write-Host "3. BACKEND ÜBERWACHEN:" -ForegroundColor Yellow
+Write-Host " - pm2 status (Server Status prüfen)" -ForegroundColor White
+Write-Host " - pm2 logs emailsorter-api (Logs ansehen)" -ForegroundColor White
+Write-Host " - pm2 monit (Live Monitoring)" -ForegroundColor White
+Write-Host ""
+Write-Host "========================================" -ForegroundColor Cyan
+
+Set-Location $PSScriptRoot