fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen

- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler

- Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto

- Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example

Made-with: Cursor
This commit is contained in:
2026-04-03 00:23:01 +02:00
parent 61008b63bb
commit ecae89a79d
33 changed files with 1663 additions and 550 deletions

View File

@@ -1,9 +1,16 @@
# Appwrite Configuration # Appwrite Configuration (Express / node-appwrite)
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1 APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_PROJECT_ID=your_project_id_here APPWRITE_PROJECT_ID=your_project_id_here
# React (Vite): separate file client/.env — see client/.env.example (VITE_APPWRITE_*).
# APPWRITE_ENDPOINT + APPWRITE_PROJECT_ID here must match the real Appwrite project (same IDs as in client/.env).
# The Express server calls Appwrite directly, not through the Vite dev proxy (localhost:5173).
APPWRITE_API_KEY=your_api_key_here APPWRITE_API_KEY=your_api_key_here
APPWRITE_DATABASE_ID=your_database_id_here APPWRITE_DATABASE_ID=your_database_id_here
# Optional: JWT for automated API tests against protected routes (create via Appwrite client: account.createJWT)
# APPWRITE_TEST_JWT=
# Database Configuration (for bootstrap script) # Database Configuration (for bootstrap script)
DB_ID=your_database_id_here DB_ID=your_database_id_here
DB_NAME=EmailSorter DB_NAME=EmailSorter
@@ -23,6 +30,9 @@ PRODUCT_CURRENCY=eur
# Stripe Configuration # Stripe Configuration
STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# STRIPE_PRICE_BASIC=price_xxx
# STRIPE_PRICE_PRO=price_xxx
# STRIPE_PRICE_BUSINESS=price_xxx
# Gitea Webhook (Deployment) # Gitea Webhook (Deployment)
# Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich) # Secret für X-Gitea-Signature und optional Bearer-Header (gleicher Wert möglich)
@@ -33,3 +43,37 @@ GITEA_WEBHOOK_SECRET=your_webhook_secret_here
# Server Configuration # Server Configuration
PORT=3000 PORT=3000
BASE_URL=http://localhost:3000 BASE_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173
# CORS_ORIGIN=http://localhost:5173
# OAuth (optional; Gmail / Outlook)
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/gmail/callback
# MICROSOFT_CLIENT_ID=
# MICROSOFT_CLIENT_SECRET=
# MICROSOFT_REDIRECT_URI=http://localhost:3000/api/oauth/outlook/callback
# HMAC secret for OAuth state (recommended in production). If unset, state is unsigned JSON (dev only).
# OAUTH_STATE_SECRET=
# Mistral AI (email categorization)
# MISTRAL_API_KEY=
# IMAP credential encryption (64 hex chars = 32-byte AES key). If unset, IMAP passwords are stored plaintext.
# Generate: openssl rand -hex 32
# ENCRYPTION_KEY=
# SMTP (Stripe lifecycle & system emails via nodemailer). If unset, emails are skipped (logged).
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM=noreply@example.com
# Admin emails (comma-separated): name-labels, /api/me isAdmin, unlimited sort, effective subscription = top tier (see TOP_SUBSCRIPTION_PLAN)
# ADMIN_EMAILS=support@webklar.com
# Highest tier id (must match a key in server stripe PLANS: basic | pro | business). Admin comped plan uses this.
# TOP_SUBSCRIPTION_PLAN=business

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Environment variables # Environment variables
.env .env
server/.env server/.env
client/.env
# Node modules # Node modules
node_modules/ node_modules/

25
client/.env.example Normal file
View File

@@ -0,0 +1,25 @@
# Kopiere nach .env — Vite neu starten nach Änderungen.
VITE_APPWRITE_PROJECT_ID=
# Wähle EINE Variante:
# B) Self-hosted / nur Produktions-Domain in Appwrite erlaubt → Vite leitet /v1 weiter (kein CORS-Problem)
# APPWRITE_DEV_ORIGIN=https://dein-appwrite-host.tld
# VITE_APPWRITE_ENDPOINT=http://localhost:5173/v1
# A) Appwrite-Webplattform enthält „localhost“ (oder 127.0.0.1) → direkte URL, APPWRITE_DEV_ORIGIN leer lassen
# APPWRITE_DEV_ORIGIN=
# VITE_APPWRITE_ENDPOINT=https://appwrite.webklar.com/v1
# Cloud-Beispiel (ohne Proxy):
# APPWRITE_DEV_ORIGIN=
# VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
# Backend: leer = Browser ruft /api auf (Vite-Proxy → 127.0.0.1:3000/api/…).
# Nicht VITE_APPWRITE_ENDPOINT (/v1) als VITE_API_URL verwenden — sonst 404.
# Wenn /api 404: nur EIN Vite auf 5173 (strictPort) — anderen Prozess auf 5173 beenden oder FRONTEND_URL anpassen.
# Wenn /api im Browser 404 liefert: PORT prüfen und ggf. Proxy-Ziel setzen:
# VITE_DEV_API_ORIGIN=http://127.0.0.1:3000
# Optional direkt (ohne Proxy): http://localhost:3000 — fehlendes /api wird ergänzt.
# VITE_API_URL=

View File

@@ -1,11 +1,11 @@
{ {
"name": "emailsorter-client", "name": "mailflow-client",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "emailsorter-client", "name": "mailflow-client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",

View File

@@ -1,8 +1,31 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import React, { createContext, useContext, useEffect, useState } from 'react' import React, { createContext, useContext, useEffect, useState } from 'react'
import { AppwriteException } from 'appwrite'
import { auth } from '@/lib/appwrite' import { auth } from '@/lib/appwrite'
import type { Models } from 'appwrite' import type { Models } from 'appwrite'
function mapLoginError(err: unknown): Error {
if (err instanceof AppwriteException) {
const type = (err.type || '').toLowerCase()
const msg = (err.message || '').toLowerCase()
if (type.includes('user_blocked') || msg.includes('blocked')) {
return new Error('Dieser Account ist gesperrt. Bitte Support kontaktieren.')
}
if (err.code === 401) {
return new Error(
'Anmeldung fehlgeschlagen. Häufige Ursachen: falsches Passwort; User wurde nur in der Console angelegt ohne Passwort; E-Mail/Passwort-Login ist im Appwrite-Projekt deaktiviert; oder die VITE_APPWRITE_PROJECT_ID passt nicht zu dem Projekt, in dem der User liegt.'
)
}
if (err.message) {
return new Error(err.message)
}
}
if (err instanceof Error) {
return err
}
return new Error('Login fehlgeschlagen.')
}
interface AuthContextType { interface AuthContextType {
user: Models.User<Models.Preferences> | null user: Models.User<Models.Preferences> | null
loading: boolean loading: boolean
@@ -36,13 +59,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []) }, [])
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
await auth.login(email, password) try {
await refreshUser() await auth.login(email, password)
await refreshUser()
} catch (e) {
throw mapLoginError(e)
}
} }
const register = async (email: string, password: string, name?: string) => { const register = async (email: string, password: string, name?: string) => {
await auth.register(email, password, name) try {
await refreshUser() await auth.register(email, password, name)
await refreshUser()
} catch (e) {
throw mapLoginError(e)
}
} }
const logout = async () => { const logout = async () => {

View File

@@ -1,3 +1,5 @@
import { getApiJwt } from './appwrite'
/** /**
* Analytics & Tracking Utility * Analytics & Tracking Utility
* Handles UTM parameter tracking and event analytics * Handles UTM parameter tracking and event analytics
@@ -162,17 +164,17 @@ export async function trackEvent(
} }
try { try {
// Send to your analytics endpoint const jwt = await getApiJwt()
if (!jwt) return
await fetch('/api/analytics/track', { await fetch('/api/analytics/track', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${jwt}`,
}, },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}).catch(() => { }).catch(() => {})
// Silently fail if analytics endpoint doesn't exist yet
// This allows graceful degradation
})
// Also log to console in development // Also log to console in development
if (import.meta.env.DEV) { if (import.meta.env.DEV) {

View File

@@ -1,4 +1,62 @@
const API_BASE = import.meta.env.VITE_API_URL || '/api' import { getApiJwt } from './appwrite'
/**
* Endpoints in this file are paths like `/subscription/status`.
* Express mounts the API under `/api`, so the base must end with `/api`.
* If VITE_API_URL is `http://localhost:3000` (missing /api), requests would 404.
*/
function resolveApiBase(): string {
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim()
if (!raw) return '/api'
if (raw.startsWith('/')) {
const p = raw.replace(/\/+$/, '') || '/api'
return p
}
if (!/^https?:\/\//i.test(raw)) {
const p = raw.replace(/^\/+/, '').replace(/\/+$/, '') || 'api'
return p.startsWith('api') ? `/${p}` : `/api`
}
const normalized = raw.replace(/\/+$/, '')
try {
const u = new URL(normalized)
const path = u.pathname.replace(/\/$/, '') || '/'
// Same host as Vite + /v1 = Appwrite-Proxy; never append /api (would hit /v1/api/… → 404)
const localVite =
/^(localhost|127\.0\.0\.1)$/i.test(u.hostname) &&
(u.port === '5173' || (u.port === '' && u.hostname === 'localhost'))
if (path === '/v1' && localVite) {
return '/api'
}
if (path === '/' || path === '') {
return `${normalized}/api`
}
if (path.endsWith('/api')) {
return normalized
}
return `${normalized}/api`
} catch {
return '/api'
}
}
const API_BASE = resolveApiBase()
/** Root-relative or absolute API URL; avoids `api/foo` (relative to current route). */
function joinApiUrl(base: string, endpoint: string): string {
const ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
if (/^https?:\/\//i.test(base)) {
return `${base.replace(/\/+$/, '')}${ep}`
}
let b = base.trim()
if (!b.startsWith('/')) {
b = `/${b.replace(/^\/+/, '')}`
}
b = b.replace(/\/+$/, '') || '/api'
return `${b}${ep}`
}
interface ApiResponse<T> { interface ApiResponse<T> {
success?: boolean success?: boolean
@@ -17,32 +75,58 @@ async function fetchApi<T>(
options?: RequestInit options?: RequestInit
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
try { try {
const response = await fetch(`${API_BASE}${endpoint}`, { const headers: Record<string, string> = {
...(options?.headers as Record<string, string>),
}
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json'
}
const jwt = await getApiJwt()
if (jwt) {
headers['Authorization'] = `Bearer ${jwt}`
}
const urlJoined = joinApiUrl(API_BASE, endpoint)
const response = await fetch(urlJoined, {
...options, ...options,
headers: { headers,
'Content-Type': 'application/json',
...options?.headers,
},
}) })
const data = await response.json() const ct = response.headers.get('content-type') || ''
const isJson = ct.includes('application/json')
const data = isJson
? await response.json()
: { success: false as const, error: undefined }
if (!response.ok || data.success === false) { if (!isJson) {
return { return {
error: data.error || { error: {
code: 'UNKNOWN', code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE',
message: `HTTP ${response.status}` message:
} response.status === 404
? 'API 404: backend unreachable or wrong port — check server is running and VITE_DEV_API_ORIGIN matches PORT.'
: `Expected JSON, got ${ct || 'unknown'} (HTTP ${response.status})`,
},
} }
} }
return { success: true, data: data.data || data } if (!response.ok || data.success === false) {
return {
error: data.error || {
code: 'UNKNOWN',
message: `HTTP ${response.status}`,
},
}
}
return { success: true, data: data.data ?? data }
} catch (error) { } catch (error) {
return { return {
error: { error: {
code: 'NETWORK_ERROR', code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network error' message: error instanceof Error ? error.message : 'Network error',
} },
} }
} }
} }
@@ -51,32 +135,34 @@ export const api = {
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// EMAIL ACCOUNTS // EMAIL ACCOUNTS
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getEmailAccounts(userId: string) { async getEmailAccounts() {
return fetchApi<Array<{ return fetchApi<Array<{
id: string id: string
email: string email: string
provider: 'gmail' | 'outlook' | 'imap' provider: 'gmail' | 'outlook' | 'imap'
connected: boolean connected: boolean
lastSync?: string lastSync?: string
}>>(`/email/accounts?userId=${userId}`) }>>('/email/accounts')
}, },
async connectEmailAccount(userId: string, provider: 'gmail' | 'outlook', email: string, accessToken: string, refreshToken?: string) { async connectEmailAccount(provider: 'gmail' | 'outlook', email: string, accessToken: string, refreshToken?: string) {
return fetchApi<{ accountId: string }>('/email/connect', { return fetchApi<{ accountId: string }>('/email/connect', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, provider, email, accessToken, refreshToken }), body: JSON.stringify({ provider, email, accessToken, refreshToken }),
}) })
}, },
async connectImapAccount( async connectImapAccount(params: {
userId: string, email: string
params: { email: string; password: string; imapHost?: string; imapPort?: number; imapSecure?: boolean } password: string
) { imapHost?: string
imapPort?: number
imapSecure?: boolean
}) {
return fetchApi<{ accountId: string }>('/email/connect', { return fetchApi<{ accountId: string }>('/email/connect', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
userId,
provider: 'imap', provider: 'imap',
email: params.email, email: params.email,
accessToken: params.password, accessToken: params.password,
@@ -87,8 +173,8 @@ export const api = {
}) })
}, },
async disconnectEmailAccount(accountId: string, userId: string) { async disconnectEmailAccount(accountId: string) {
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, { return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}`, {
method: 'DELETE', method: 'DELETE',
}) })
}, },
@@ -97,17 +183,17 @@ export const api = {
// EMAIL STATS & SORTING // EMAIL STATS & SORTING
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getEmailStats(userId: string) { async getEmailStats() {
return fetchApi<{ return fetchApi<{
totalSorted: number totalSorted: number
todaySorted: number todaySorted: number
weekSorted: number weekSorted: number
categories: Record<string, number> categories: Record<string, number>
timeSaved: number timeSaved: number
}>(`/email/stats?userId=${userId}`) }>('/email/stats')
}, },
async sortEmails(userId: string, accountId: string, maxEmails?: number, processAll?: boolean) { async sortEmails(accountId: string, maxEmails?: number, processAll?: boolean) {
return fetchApi<{ return fetchApi<{
sorted: number sorted: number
inboxCleared: number inboxCleared: number
@@ -127,11 +213,10 @@ export const api = {
}> }>
}>('/email/sort', { }>('/email/sort', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, accountId, maxEmails, processAll }), body: JSON.stringify({ accountId, maxEmails, processAll }),
}) })
}, },
// Demo sorting without account (for quick tests)
async sortDemo(count: number = 10) { async sortDemo(count: number = 10) {
return fetchApi<{ return fetchApi<{
sorted: number sorted: number
@@ -152,8 +237,7 @@ export const api = {
}) })
}, },
// Connect demo account async connectDemoAccount() {
async connectDemoAccount(userId: string) {
return fetchApi<{ return fetchApi<{
accountId: string accountId: string
email: string email: string
@@ -161,11 +245,10 @@ export const api = {
message?: string message?: string
}>('/email/connect-demo', { }>('/email/connect-demo', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId }), body: JSON.stringify({}),
}) })
}, },
// Get categories
async getCategories() { async getCategories() {
return fetchApi<Array<{ return fetchApi<Array<{
id: string id: string
@@ -177,8 +260,7 @@ export const api = {
}>>('/email/categories') }>>('/email/categories')
}, },
// Get today's digest async getDigest() {
async getDigest(userId: string) {
return fetchApi<{ return fetchApi<{
date: string date: string
totalSorted: number totalSorted: number
@@ -188,11 +270,10 @@ export const api = {
highlights: Array<{ type: string; count: number; message: string }> highlights: Array<{ type: string; count: number; message: string }>
suggestions: Array<{ type: string; message: string }> suggestions: Array<{ type: string; message: string }>
hasData: boolean hasData: boolean
}>(`/email/digest?userId=${userId}`) }>('/email/digest')
}, },
// Get digest history async getDigestHistory(days: number = 7) {
async getDigestHistory(userId: string, days: number = 7) {
return fetchApi<{ return fetchApi<{
days: number days: number
digests: Array<{ digests: Array<{
@@ -207,15 +288,15 @@ export const api = {
inboxCleared: number inboxCleared: number
timeSavedMinutes: number timeSavedMinutes: number
} }
}>(`/email/digest/history?userId=${userId}&days=${days}`) }>(`/email/digest/history?days=${days}`)
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// OAUTH // OAUTH
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getOAuthUrl(provider: 'gmail' | 'outlook', userId: string) { async getOAuthUrl(provider: 'gmail' | 'outlook') {
return fetchApi<{ url: string }>(`/oauth/${provider}/connect?userId=${userId}`) return fetchApi<{ url: string }>(`/oauth/${provider}/connect`)
}, },
async getOAuthStatus() { async getOAuthStatus() {
@@ -229,10 +310,11 @@ export const api = {
// SUBSCRIPTION // SUBSCRIPTION
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getSubscriptionStatus(userId: string) { async getSubscriptionStatus() {
return fetchApi<{ return fetchApi<{
status: string status: string
plan: string plan: string
planDisplayName?: string
isFreeTier: boolean isFreeTier: boolean
emailsUsedThisMonth?: number emailsUsedThisMonth?: number
emailsLimit?: number emailsLimit?: number
@@ -245,34 +327,34 @@ export const api = {
} }
currentPeriodEnd?: string currentPeriodEnd?: string
cancelAtPeriodEnd?: boolean cancelAtPeriodEnd?: boolean
}>(`/subscription/status?userId=${userId}`) }>('/subscription/status')
}, },
async createSubscriptionCheckout(plan: string, userId: string, email?: string) { async createSubscriptionCheckout(plan: string, email?: string) {
return fetchApi<{ url: string; sessionId: string }>('/subscription/checkout', { return fetchApi<{ url: string; sessionId: string }>('/subscription/checkout', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, plan, email }), body: JSON.stringify({ plan, email }),
}) })
}, },
async createPortalSession(userId: string) { async createPortalSession() {
return fetchApi<{ url: string }>('/subscription/portal', { return fetchApi<{ url: string }>('/subscription/portal', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId }), body: JSON.stringify({}),
}) })
}, },
async cancelSubscription(userId: string) { async cancelSubscription() {
return fetchApi<{ success: boolean }>('/subscription/cancel', { return fetchApi<{ success: boolean }>('/subscription/cancel', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId }), body: JSON.stringify({}),
}) })
}, },
async reactivateSubscription(userId: string) { async reactivateSubscription() {
return fetchApi<{ success: boolean }>('/subscription/reactivate', { return fetchApi<{ success: boolean }>('/subscription/reactivate', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId }), body: JSON.stringify({}),
}) })
}, },
@@ -280,16 +362,21 @@ export const api = {
// USER PREFERENCES // USER PREFERENCES
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getUserPreferences(userId: string) { async getUserPreferences() {
return fetchApi<{ return fetchApi<{
vipSenders: Array<{ email: string; name?: string }> vipSenders: Array<{ email: string; name?: string }>
blockedSenders: string[] blockedSenders: string[]
customRules: Array<{ condition: string; category: string }> customRules: Array<{ condition: string; category: string }>
priorityTopics: string[] priorityTopics: string[]
}>(`/preferences?userId=${userId}`) profile?: {
displayName?: string
timezone?: string
notificationPrefs?: Record<string, unknown>
}
}>('/preferences')
}, },
async saveUserPreferences(userId: string, preferences: { async saveUserPreferences(preferences: {
vipSenders?: Array<{ email: string; name?: string }> vipSenders?: Array<{ email: string; name?: string }>
blockedSenders?: string[] blockedSenders?: string[]
customRules?: Array<{ condition: string; category: string }> customRules?: Array<{ condition: string; category: string }>
@@ -298,7 +385,7 @@ export const api = {
}) { }) {
return fetchApi<{ success: boolean }>('/preferences', { return fetchApi<{ success: boolean }>('/preferences', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, ...preferences }), body: JSON.stringify(preferences),
}) })
}, },
@@ -306,7 +393,7 @@ export const api = {
// AI CONTROL // AI CONTROL
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getAIControlSettings(userId: string) { async getAIControlSettings() {
return fetchApi<{ return fetchApi<{
enabledCategories: string[] enabledCategories: string[]
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'> categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
@@ -314,10 +401,10 @@ export const api = {
cleanup?: unknown cleanup?: unknown
categoryAdvanced?: Record<string, unknown> categoryAdvanced?: Record<string, unknown>
version?: number version?: number
}>(`/preferences/ai-control?userId=${userId}`) }>('/preferences/ai-control')
}, },
async saveAIControlSettings(userId: string, settings: { async saveAIControlSettings(settings: {
enabledCategories?: string[] enabledCategories?: string[]
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'> categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies?: boolean autoDetectCompanies?: boolean
@@ -327,33 +414,24 @@ export const api = {
}) { }) {
return fetchApi<{ success: boolean }>('/preferences/ai-control', { return fetchApi<{ success: boolean }>('/preferences/ai-control', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, ...settings }), body: JSON.stringify(settings),
}) })
}, },
// Cleanup Preview - shows what would be cleaned up without actually doing it async getCleanupPreview(accountId: string) {
// TODO: Backend endpoint needs to be implemented
// GET /api/preferences/ai-control/cleanup/preview?userId=xxx
// Response: { preview: Array<{id, subject, from, date, reason}> }
async getCleanupPreview(userId: string) {
// TODO: Implement backend endpoint
return fetchApi<{ return fetchApi<{
preview: Array<{ messages: Array<{
id: string id: string
subject: string subject: string
from: string from: string
date: string date: string
reason: 'read' | 'promotion' reason: 'read' | 'promotion'
}> }>
}>(`/preferences/ai-control/cleanup/preview?userId=${userId}`) count: number
}>(`/email/${accountId}/cleanup/preview`)
}, },
// Run cleanup now - executes cleanup for the user async runCleanup() {
// POST /api/preferences/ai-control/cleanup/run
// Body: { userId: string }
// Response: { success: boolean, data: { readItems: number, promotions: number } }
async runCleanup(userId: string) {
// Uses existing /api/email/cleanup endpoint
return fetchApi<{ return fetchApi<{
usersProcessed: number usersProcessed: number
emailsProcessed: { emailsProcessed: {
@@ -363,40 +441,47 @@ export const api = {
errors: Array<{ userId: string; error: string }> errors: Array<{ userId: string; error: string }>
}>('/email/cleanup', { }>('/email/cleanup', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId }), body: JSON.stringify({}),
}) })
}, },
// Get cleanup status - last run info and counts async getCleanupStatus(accountId: string) {
// TODO: Backend endpoint needs to be implemented
// GET /api/preferences/ai-control/cleanup/status?userId=xxx
// Response: { lastRun?: string, lastRunCounts?: { readItems: number, promotions: number } }
async getCleanupStatus(userId: string) {
// TODO: Implement backend endpoint
return fetchApi<{ return fetchApi<{
lastRun?: string lastRun?: string
lastRunCounts?: { lastRunCounts?: {
readItems: number readItems: number
promotions: number promotions: number
} }
}>(`/preferences/ai-control/cleanup/status?userId=${userId}`) lastErrors?: string[]
}>(`/email/${accountId}/cleanup/status`)
},
async updateProfile(payload: {
displayName?: string
timezone?: string
notificationPrefs?: Record<string, unknown>
}) {
return fetchApi<{ success: boolean }>('/preferences/profile', {
method: 'PATCH',
body: JSON.stringify(payload),
})
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// COMPANY LABELS // COMPANY LABELS
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getCompanyLabels(userId: string) { async getCompanyLabels() {
return fetchApi<Array<{ return fetchApi<Array<{
id?: string id?: string
name: string name: string
condition: string condition: string
enabled: boolean enabled: boolean
category?: string category?: string
}>>(`/preferences/company-labels?userId=${userId}`) }>>('/preferences/company-labels')
}, },
async saveCompanyLabel(userId: string, companyLabel: { async saveCompanyLabel(companyLabel: {
id?: string id?: string
name: string name: string
condition: string condition: string
@@ -411,12 +496,12 @@ export const api = {
category?: string category?: string
}>('/preferences/company-labels', { }>('/preferences/company-labels', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, companyLabel }), body: JSON.stringify({ companyLabel }),
}) })
}, },
async deleteCompanyLabel(userId: string, labelId: string) { async deleteCompanyLabel(labelId: string) {
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}?userId=${userId}`, { return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}`, {
method: 'DELETE', method: 'DELETE',
}) })
}, },
@@ -425,43 +510,36 @@ export const api = {
// ME / ADMIN // ME / ADMIN
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getMe(email: string) { async getMe() {
return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`) return fetchApi<{ isAdmin: boolean }>('/me')
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// NAME LABELS (Workers Admin only) // NAME LABELS (Workers Admin only)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getNameLabels(userId: string, email: string) { async getNameLabels() {
return fetchApi<Array<{ return fetchApi<Array<{
id?: string id?: string
name: string name: string
email?: string email?: string
keywords?: string[] keywords?: string[]
enabled: boolean enabled: boolean
}>>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`) }>>('/preferences/name-labels')
}, },
async saveNameLabel( async saveNameLabel(nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }) {
userId: string,
userEmail: string,
nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }
) {
return fetchApi<{ id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }>( return fetchApi<{ id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }>(
'/preferences/name-labels', '/preferences/name-labels',
{ {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, email: userEmail, nameLabel }), body: JSON.stringify({ nameLabel }),
} }
) )
}, },
async deleteNameLabel(userId: string, userEmail: string, labelId: string) { async deleteNameLabel(labelId: string) {
return fetchApi<{ success: boolean }>( return fetchApi<{ success: boolean }>(`/preferences/name-labels/${labelId}`, { method: 'DELETE' })
`/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`,
{ method: 'DELETE' }
)
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -523,36 +601,36 @@ export const api = {
// ONBOARDING // ONBOARDING
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getOnboardingStatus(userId: string) { async getOnboardingStatus() {
return fetchApi<{ return fetchApi<{
onboarding_step: string onboarding_step: string
completedSteps: string[] completedSteps: string[]
first_value_seen_at?: string first_value_seen_at?: string
skipped_at?: string skipped_at?: string
}>(`/onboarding/status?userId=${userId}`) }>('/onboarding/status')
}, },
async updateOnboardingStep(userId: string, step: string, completedSteps: string[] = []) { async updateOnboardingStep(step: string, completedSteps: string[] = []) {
return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', { return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, step, completedSteps }), body: JSON.stringify({ step, completedSteps }),
}) })
}, },
async skipOnboarding(userId: string) { async skipOnboarding() {
return fetchApi<{ skipped: boolean }>('/onboarding/skip', { return fetchApi<{ skipped: boolean }>('/onboarding/skip', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId }), body: JSON.stringify({}),
}) })
}, },
async resumeOnboarding(userId: string) { async resumeOnboarding() {
return fetchApi<{ return fetchApi<{
onboarding_step: string onboarding_step: string
completedSteps: string[] completedSteps: string[]
}>('/onboarding/resume', { }>('/onboarding/resume', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId }), body: JSON.stringify({}),
}) })
}, },
@@ -560,10 +638,10 @@ export const api = {
// ACCOUNT MANAGEMENT // ACCOUNT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async deleteAccount(userId: string) { async deleteAccount() {
return fetchApi<{ success: boolean }>('/account/delete', { return fetchApi<{ success: boolean }>('/account/delete', {
method: 'DELETE', method: 'DELETE',
body: JSON.stringify({ userId }), body: JSON.stringify({}),
}) })
}, },
@@ -571,17 +649,17 @@ export const api = {
// REFERRALS // REFERRALS
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
async getReferralCode(userId: string) { async getReferralCode() {
return fetchApi<{ return fetchApi<{
referralCode: string referralCode: string
referralCount: number referralCount: number
}>(`/referrals/code?userId=${userId}`) }>('/referrals/code')
}, },
async trackReferral(userId: string, referralCode: string) { async trackReferral(referralCode: string) {
return fetchApi<{ success: boolean }>('/referrals/track', { return fetchApi<{ success: boolean }>('/referrals/track', {
method: 'POST', method: 'POST',
body: JSON.stringify({ userId, referralCode }), body: JSON.stringify({ referralCode }),
}) })
}, },
} }

View File

@@ -4,20 +4,89 @@ const client = new Client()
// Configure these in your .env file // Configure these in your .env file
const APPWRITE_ENDPOINT = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1' const APPWRITE_ENDPOINT = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://cloud.appwrite.io/v1'
const APPWRITE_PROJECT_ID = import.meta.env.VITE_APPWRITE_PROJECT_ID || '' const APPWRITE_PROJECT_ID = (import.meta.env.VITE_APPWRITE_PROJECT_ID || '').trim()
/** Ohne Project ID keine gültigen Appwrite-Requests — sonst nur 401-Spam im Netzwerk-Tab. */
export function isAppwriteClientConfigured(): boolean {
return APPWRITE_PROJECT_ID.length > 0
}
function assertAppwriteConfigured(): void {
if (!isAppwriteClientConfigured()) {
throw new Error(
'Appwrite ist nicht konfiguriert: Lege client/.env an und setze VITE_APPWRITE_PROJECT_ID (siehe client/.env.example). Danach Vite neu starten.'
)
}
}
client client
.setEndpoint(APPWRITE_ENDPOINT) .setEndpoint(APPWRITE_ENDPOINT)
.setProject(APPWRITE_PROJECT_ID) .setProject(APPWRITE_PROJECT_ID)
if (import.meta.env.DEV && !isAppwriteClientConfigured()) {
console.warn(
'[MailFlow] VITE_APPWRITE_PROJECT_ID fehlt → keine Appwrite-Aufrufe bis client/.env gesetzt ist. Kopiere client/.env.example nach client/.env.'
)
}
/** Vite-Dev mit Proxy: Endpoint zeigt auf localhost → Session liegt in cookieFallback (localStorage). */
function isLocalViteAppwriteProxy(): boolean {
if (typeof window === 'undefined') return false
return /localhost|127\.0\.0\.1/.test(APPWRITE_ENDPOINT)
}
function hasCookieFallbackSession(): boolean {
if (typeof window === 'undefined' || !APPWRITE_PROJECT_ID) return false
try {
const raw = window.localStorage.getItem('cookieFallback')
if (!raw) return false
const o = JSON.parse(raw) as Record<string, unknown>
return Boolean(o[`a_session_${APPWRITE_PROJECT_ID}`])
} catch {
return false
}
}
export const account = new Account(client) export const account = new Account(client)
export const databases = new Databases(client) export const databases = new Databases(client)
export { ID } export { ID }
const JWT_BUFFER_MS = 30_000
let jwtCache: { token: string; expMs: number } | null = null
export function clearApiJwtCache() {
jwtCache = null
}
/** Short-lived JWT for MailFlow API (Bearer). Cached until near expiry. */
export async function getApiJwt(): Promise<string | null> {
if (!isAppwriteClientConfigured()) {
return null
}
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
return null
}
try {
const now = Date.now()
if (jwtCache && jwtCache.expMs > now + JWT_BUFFER_MS) {
return jwtCache.token
}
const res = await account.createJWT()
const token = res.jwt
const expireSec = (res as { expire?: number }).expire
const expMs = expireSec != null ? expireSec * 1000 : now + 14 * 60 * 1000
jwtCache = { token, expMs }
return token
} catch {
return null
}
}
// Auth helper functions // Auth helper functions
export const auth = { export const auth = {
// Create a new account // Create a new account
async register(email: string, password: string, name?: string) { async register(email: string, password: string, name?: string) {
assertAppwriteConfigured()
const user = await account.create(ID.unique(), email, password, name) const user = await account.create(ID.unique(), email, password, name)
await this.login(email, password) await this.login(email, password)
return user return user
@@ -25,16 +94,27 @@ export const auth = {
// Login with email and password // Login with email and password
async login(email: string, password: string) { async login(email: string, password: string) {
assertAppwriteConfigured()
return await account.createEmailPasswordSession(email, password) return await account.createEmailPasswordSession(email, password)
}, },
// Logout current session // Logout current session
async logout() { async logout() {
clearApiJwtCache()
if (!isAppwriteClientConfigured()) {
return
}
return await account.deleteSession('current') return await account.deleteSession('current')
}, },
// Get current logged in user // Get current logged in user
async getCurrentUser() { async getCurrentUser() {
if (!isAppwriteClientConfigured()) {
return null
}
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
return null
}
try { try {
return await account.get() return await account.get()
} catch { } catch {
@@ -44,6 +124,12 @@ export const auth = {
// Check if user is logged in // Check if user is logged in
async isLoggedIn() { async isLoggedIn() {
if (!isAppwriteClientConfigured()) {
return false
}
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
return false
}
try { try {
await account.get() await account.get()
return true return true
@@ -54,6 +140,7 @@ export const auth = {
// Send password recovery email // Send password recovery email
async forgotPassword(email: string) { async forgotPassword(email: string) {
assertAppwriteConfigured()
return await account.createRecovery( return await account.createRecovery(
email, email,
`${window.location.origin}/reset-password` `${window.location.origin}/reset-password`
@@ -62,11 +149,13 @@ export const auth = {
// Complete password recovery // Complete password recovery
async resetPassword(userId: string, secret: string, newPassword: string) { async resetPassword(userId: string, secret: string, newPassword: string) {
assertAppwriteConfigured()
return await account.updateRecovery(userId, secret, newPassword) return await account.updateRecovery(userId, secret, newPassword)
}, },
// Send verification email // Send verification email
async sendVerification() { async sendVerification() {
assertAppwriteConfigured()
return await account.createVerification( return await account.createVerification(
`${window.location.origin}/verify` `${window.location.origin}/verify`
) )
@@ -74,6 +163,7 @@ export const auth = {
// Complete email verification // Complete email verification
async verifyEmail(userId: string, secret: string) { async verifyEmail(userId: string, secret: string) {
assertAppwriteConfigured()
return await account.updateVerification(userId, secret) return await account.updateVerification(userId, secret)
}, },
} }

View File

@@ -86,6 +86,7 @@ export function Dashboard() {
const [digest, setDigest] = useState<Digest | null>(null) const [digest, setDigest] = useState<Digest | null>(null)
const [subscription, setSubscription] = useState<{ const [subscription, setSubscription] = useState<{
plan: string plan: string
planDisplayName?: string
isFreeTier: boolean isFreeTier: boolean
emailsUsedThisMonth?: number emailsUsedThisMonth?: number
emailsLimit?: number emailsLimit?: number
@@ -115,11 +116,11 @@ export function Dashboard() {
try { try {
const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([ const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([
api.getEmailStats(user.$id), api.getEmailStats(),
api.getEmailAccounts(user.$id), api.getEmailAccounts(),
api.getDigest(user.$id), api.getDigest(),
api.getSubscriptionStatus(user.$id), api.getSubscriptionStatus(),
api.getReferralCode(user.$id).catch(() => ({ data: null })), api.getReferralCode().catch(() => ({ data: null })),
]) ])
if (statsRes.data) setStats(statsRes.data) if (statsRes.data) setStats(statsRes.data)
@@ -146,7 +147,7 @@ export function Dashboard() {
setError(null) setError(null)
try { try {
const result = await api.sortEmails(user.$id, accounts[0].id) const result = await api.sortEmails(accounts[0].id)
if (result.data) { if (result.data) {
setSortResult(result.data) setSortResult(result.data)
@@ -155,9 +156,9 @@ export function Dashboard() {
// Refresh stats, digest, and subscription // Refresh stats, digest, and subscription
const [statsRes, digestRes, subscriptionRes] = await Promise.all([ const [statsRes, digestRes, subscriptionRes] = await Promise.all([
api.getEmailStats(user.$id), api.getEmailStats(),
api.getDigest(user.$id), api.getDigest(),
api.getSubscriptionStatus(user.$id), api.getSubscriptionStatus(),
]) ])
if (statsRes.data) setStats(statsRes.data) if (statsRes.data) setStats(statsRes.data)
if (digestRes.data) setDigest(digestRes.data) if (digestRes.data) setDigest(digestRes.data)
@@ -168,7 +169,7 @@ export function Dashboard() {
setError(result.error.message || 'Monthly limit reached') setError(result.error.message || 'Monthly limit reached')
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500) trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
// Refresh subscription to show updated usage // Refresh subscription to show updated usage
const subscriptionRes = await api.getSubscriptionStatus(user.$id) const subscriptionRes = await api.getSubscriptionStatus()
if (subscriptionRes.data) setSubscription(subscriptionRes.data) if (subscriptionRes.data) setSubscription(subscriptionRes.data)
} else { } else {
setError(result.error.message || 'Email sorting failed. Please try again or reconnect your account.') setError(result.error.message || 'Email sorting failed. Please try again or reconnect your account.')
@@ -504,7 +505,7 @@ export function Dashboard() {
} }
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
await api.saveUserPreferences(user.$id, updates) await api.saveUserPreferences(updates)
trackRulesApplied(user.$id, sortResult.suggestedRules.length) trackRulesApplied(user.$id, sortResult.suggestedRules.length)
showMessage('success', `${sortResult.suggestedRules.length} rules applied. Your inbox will stay organized.`) showMessage('success', `${sortResult.suggestedRules.length} rules applied. Your inbox will stay organized.`)
setSortResult({ ...sortResult, suggestedRules: [] }) setSortResult({ ...sortResult, suggestedRules: [] })
@@ -834,7 +835,12 @@ export function Dashboard() {
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300">Subscription</p> <p className="text-xs font-medium text-slate-700 dark:text-slate-300">Subscription</p>
<Badge variant={subscription?.isFreeTier ? 'secondary' : 'default'} className="text-xs"> <Badge variant={subscription?.isFreeTier ? 'secondary' : 'default'} className="text-xs">
{subscription?.plan === 'free' ? 'Free Tier' : subscription?.plan || 'Free Tier'} {subscription?.isFreeTier
? 'Free Tier'
: subscription?.planDisplayName ||
(subscription?.plan
? subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
: 'Business')}
</Badge> </Badge>
</div> </div>
{subscription?.isFreeTier && subscription.emailsLimit && ( {subscription?.isFreeTier && subscription.emailsLimit && (

View File

@@ -34,7 +34,7 @@ export function Register() {
useEffect(() => { useEffect(() => {
if (user?.$id && referralCode) { if (user?.$id && referralCode) {
// Track referral if code exists // Track referral if code exists
api.trackReferral(user.$id, referralCode).catch((err) => { api.trackReferral(referralCode).catch((err) => {
console.error('Failed to track referral:', err) console.error('Failed to track referral:', err)
}) })
} }

View File

@@ -60,12 +60,74 @@ import { PrivacySecurity } from '@/components/PrivacySecurity'
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals' type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals'
const HEX_COLOR = /^#([0-9A-Fa-f]{6})$/
function validateLabelImport(
imported: unknown,
existing: CompanyLabel[]
): { labels: CompanyLabel[]; errors: string[] } {
const errors: string[] = []
if (!Array.isArray(imported)) {
return { labels: [], errors: ['File must contain a JSON array'] }
}
const seen = new Set<string>()
const labels: CompanyLabel[] = []
imported.forEach((row, i) => {
const rowNum = i + 1
if (!row || typeof row !== 'object') {
errors.push(`Row ${rowNum}: invalid object`)
return
}
const r = row as Record<string, unknown>
const name = typeof r.name === 'string' ? r.name.trim() : ''
if (!name) {
errors.push(`Row ${rowNum}: name is required`)
return
}
if (name.length > 50) {
errors.push(`Row ${rowNum}: name must be at most 50 characters`)
return
}
if (r.color != null && r.color !== '') {
if (typeof r.color !== 'string' || !HEX_COLOR.test(r.color)) {
errors.push(`Row ${rowNum}: color must be a valid #RRGGBB hex`)
return
}
}
const key = name.toLowerCase()
if (seen.has(key)) {
errors.push(`Row ${rowNum}: duplicate name "${name}" in import`)
return
}
seen.add(key)
if (existing.some((e) => e.name.trim().toLowerCase() === key)) {
errors.push(`Row ${rowNum}: name "${name}" already exists`)
return
}
labels.push({
id: typeof r.id === 'string' && r.id ? r.id : `label_import_${Date.now()}_${i}`,
name,
condition: typeof r.condition === 'string' ? r.condition : '',
enabled: r.enabled !== false,
category: typeof r.category === 'string' ? r.category : 'promotions',
})
})
if (existing.length + labels.length > 100) {
return {
labels: [],
errors: [`Cannot exceed 100 labels total (have ${existing.length}, importing ${labels.length})`],
}
}
return { labels, errors }
}
interface EmailAccount { interface EmailAccount {
id: string id: string
email: string email: string
provider: 'gmail' | 'outlook' | 'imap' provider: 'gmail' | 'outlook' | 'imap' | 'demo'
connected: boolean connected: boolean
lastSync?: string lastSync?: string
isDemo?: boolean
} }
interface VIPSender { interface VIPSender {
@@ -76,10 +138,35 @@ interface VIPSender {
interface Subscription { interface Subscription {
status: string status: string
plan: string plan: string
planDisplayName?: string
isFreeTier?: boolean
currentPeriodEnd?: string currentPeriodEnd?: string
cancelAtPeriodEnd?: boolean cancelAtPeriodEnd?: boolean
} }
function subscriptionTitle(sub: Subscription | null): string {
if (!sub) return ''
if (sub.planDisplayName) return sub.planDisplayName
if (sub.plan === 'free' || sub.isFreeTier) return 'Free plan'
if (sub.plan) return sub.plan.charAt(0).toUpperCase() + sub.plan.slice(1)
return 'Subscription'
}
function subscriptionBadge(sub: Subscription | null): {
label: string
variant: 'success' | 'warning' | 'secondary'
} {
if (!sub) return { label: '', variant: 'secondary' }
if (sub.isFreeTier) return { label: 'Free plan', variant: 'secondary' }
if (sub.status === 'active') return { label: 'Active', variant: 'success' }
const s = (sub.status || '').toLowerCase()
if (s === 'trialing' || s === 'trial') return { label: 'Trial', variant: 'warning' }
return {
label: sub.status ? sub.status.charAt(0).toUpperCase() + sub.status.slice(1) : 'Inactive',
variant: 'warning',
}
}
export function Settings() { export function Settings() {
const { user } = useAuth() const { user } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
@@ -130,6 +217,7 @@ export function Settings() {
}) })
const [categories, setCategories] = useState<CategoryInfo[]>([]) const [categories, setCategories] = useState<CategoryInfo[]>([])
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([]) const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
const [labelImportErrors, setLabelImportErrors] = useState<string[]>([])
const [isAdmin, setIsAdmin] = useState(false) const [isAdmin, setIsAdmin] = useState(false)
const [nameLabels, setNameLabels] = useState<NameLabel[]>([]) const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null) const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
@@ -174,11 +262,24 @@ export function Settings() {
} }
}, [user]) }, [user])
// Refetch subscription when opening this tab (fixes JWT timing vs initial loadData)
useEffect(() => {
if (activeTab !== 'subscription' || !user?.$id) return
let cancelled = false
;(async () => {
const res = await api.getSubscriptionStatus()
if (!cancelled && res.data) setSubscription(res.data)
})()
return () => {
cancelled = true
}
}, [activeTab, user?.$id])
const loadReferralData = async () => { const loadReferralData = async () => {
if (!user?.$id) return if (!user?.$id) return
setLoadingReferral(true) setLoadingReferral(true)
try { try {
const res = await api.getReferralCode(user.$id) const res = await api.getReferralCode()
if (res.data) setReferralData(res.data) if (res.data) setReferralData(res.data)
} catch (err) { } catch (err) {
console.error('Failed to load referral data:', err) console.error('Failed to load referral data:', err)
@@ -194,24 +295,33 @@ export function Settings() {
try { try {
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([ const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
api.getEmailAccounts(user.$id), api.getEmailAccounts(),
api.getSubscriptionStatus(user.$id), api.getSubscriptionStatus(),
api.getUserPreferences(user.$id), api.getUserPreferences(),
api.getAIControlSettings(user.$id), api.getAIControlSettings(),
api.getCompanyLabels(user.$id), api.getCompanyLabels(),
user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }), user?.$id ? api.getMe() : Promise.resolve({ data: { isAdmin: false } }),
]) ])
if (accountsRes.data) setAccounts(accountsRes.data) if (accountsRes.data) setAccounts(accountsRes.data)
if (subsRes.data) setSubscription(subsRes.data) if (subsRes.data) setSubscription(subsRes.data)
if (meRes.data?.isAdmin) { if (meRes.data?.isAdmin) {
setIsAdmin(true) setIsAdmin(true)
const nameLabelsRes = await api.getNameLabels(user.$id, user.email) const nameLabelsRes = await api.getNameLabels()
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data) if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
} else { } else {
setIsAdmin(false) setIsAdmin(false)
} }
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders) if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
const pdata = prefsRes.data as {
profile?: { displayName?: string; timezone?: string; notificationPrefs?: { language?: string } }
} | undefined
if (pdata?.profile) {
const prof = pdata.profile
if (prof.displayName != null && prof.displayName !== '') setName(prof.displayName)
if (prof.timezone) setTimezone(prof.timezone)
if (prof.notificationPrefs?.language) setLanguage(String(prof.notificationPrefs.language))
}
if (aiControlRes.data) { if (aiControlRes.data) {
// Merge cleanup defaults if not present // Merge cleanup defaults if not present
const raw = aiControlRes.data const raw = aiControlRes.data
@@ -297,7 +407,7 @@ export function Settings() {
if (!user?.$id) return if (!user?.$id) return
setSaving(true) setSaving(true)
try { try {
await api.saveAIControlSettings(user.$id, { await api.saveAIControlSettings({
enabledCategories: aiControlSettings.enabledCategories, enabledCategories: aiControlSettings.enabledCategories,
categoryActions: aiControlSettings.categoryActions, categoryActions: aiControlSettings.categoryActions,
autoDetectCompanies: aiControlSettings.autoDetectCompanies, autoDetectCompanies: aiControlSettings.autoDetectCompanies,
@@ -326,24 +436,25 @@ export function Settings() {
// Load cleanup status // Load cleanup status
const loadCleanupStatus = async () => { const loadCleanupStatus = async () => {
if (!user?.$id) return const aid = accounts.find((a) => a.provider !== 'demo')?.id
if (!aid) return
try { try {
const res = await api.getCleanupStatus(user.$id) const res = await api.getCleanupStatus(aid)
if (res.data) setCleanupStatus(res.data) if (res.data) setCleanupStatus(res.data)
} catch { } catch {
// Silently fail if endpoint doesn't exist yet
console.debug('Cleanup status endpoint not available') console.debug('Cleanup status endpoint not available')
} }
} }
// Load cleanup preview // Load cleanup preview
const loadCleanupPreview = async () => { const loadCleanupPreview = async () => {
if (!user?.$id || !aiControlSettings.cleanup?.enabled) return if (!aiControlSettings.cleanup?.enabled) return
const aid = accounts.find((a) => a.provider !== 'demo')?.id
if (!aid) return
try { try {
const res = await api.getCleanupPreview(user.$id) const res = await api.getCleanupPreview(aid)
if (res.data?.preview) setCleanupPreview(res.data.preview) if (res.data?.messages) setCleanupPreview(res.data.messages)
} catch { } catch {
// Silently fail if endpoint doesn't exist yet
console.debug('Cleanup preview endpoint not available') console.debug('Cleanup preview endpoint not available')
} }
} }
@@ -356,14 +467,14 @@ export function Settings() {
loadCleanupPreview() loadCleanupPreview()
} }
} }
}, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun]) }, [activeTab, controlPanelTab, aiControlSettings.cleanup?.enabled, aiControlSettings.cleanup?.safety.dryRun, accounts])
// Run cleanup now // Run cleanup now
const handleRunCleanup = async () => { const handleRunCleanup = async () => {
if (!user?.$id) return if (!user?.$id) return
setRunningCleanup(true) setRunningCleanup(true)
try { try {
const res = await api.runCleanup(user.$id) const res = await api.runCleanup()
if (res.data) { if (res.data) {
showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`) showMessage('success', `Cleanup completed: ${res.data.emailsProcessed.readItems + res.data.emailsProcessed.promotions} emails processed`)
await loadCleanupStatus() await loadCleanupStatus()
@@ -432,8 +543,15 @@ export function Settings() {
if (!user?.$id) return if (!user?.$id) return
setSaving(true) setSaving(true)
try { try {
// TODO: Save profile data to backend const res = await api.updateProfile({
// await api.updateUserProfile(user.$id, { name, language, timezone }) displayName: name,
timezone,
notificationPrefs: { language },
})
if (res.error) {
showMessage('error', res.error.message || 'Failed to save profile')
return
}
savedProfileRef.current = { name, language, timezone } savedProfileRef.current = { name, language, timezone }
setHasProfileChanges(false) setHasProfileChanges(false)
showMessage('success', 'Profile saved successfully!') showMessage('success', 'Profile saved successfully!')
@@ -472,7 +590,7 @@ export function Settings() {
setConnectingProvider(provider) setConnectingProvider(provider)
try { try {
const res = await api.getOAuthUrl(provider, user.$id) const res = await api.getOAuthUrl(provider)
if (res.data?.url) { if (res.data?.url) {
window.location.href = res.data.url window.location.href = res.data.url
} }
@@ -486,7 +604,7 @@ export function Settings() {
if (!user?.$id) return if (!user?.$id) return
try { try {
await api.disconnectEmailAccount(accountId, user.$id) await api.disconnectEmailAccount(accountId)
setAccounts(accounts.filter(a => a.id !== accountId)) setAccounts(accounts.filter(a => a.id !== accountId))
showMessage('success', 'Account disconnected') showMessage('success', 'Account disconnected')
} catch { } catch {
@@ -498,7 +616,7 @@ export function Settings() {
e.preventDefault() e.preventDefault()
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
setImapConnecting(true) setImapConnecting(true)
const res = await api.connectImapAccount(user.$id, { const res = await api.connectImapAccount({
email: imapForm.email.trim(), email: imapForm.email.trim(),
password: imapForm.password, password: imapForm.password,
imapHost: imapForm.imapHost || undefined, imapHost: imapForm.imapHost || undefined,
@@ -511,7 +629,7 @@ export function Settings() {
setImapConnecting(false) setImapConnecting(false)
return return
} }
const list = await api.getEmailAccounts(user.$id) const list = await api.getEmailAccounts()
setAccounts(list.data ?? []) setAccounts(list.data ?? [])
setShowImapForm(false) setShowImapForm(false)
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true }) setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
@@ -541,7 +659,7 @@ export function Settings() {
setSaving(true) setSaving(true)
try { try {
await api.saveUserPreferences(user.$id, { vipSenders }) await api.saveUserPreferences({ vipSenders })
showMessage('success', 'VIP list saved!') showMessage('success', 'VIP list saved!')
} catch { } catch {
showMessage('error', 'Failed to save') showMessage('error', 'Failed to save')
@@ -554,7 +672,7 @@ export function Settings() {
if (!user?.$id) return if (!user?.$id) return
try { try {
const res = await api.createPortalSession(user.$id) const res = await api.createPortalSession()
if (res.data?.url) { if (res.data?.url) {
window.location.href = res.data.url window.location.href = res.data.url
} }
@@ -567,7 +685,7 @@ export function Settings() {
if (!user?.$id) return if (!user?.$id) return
try { try {
const res = await api.createSubscriptionCheckout(plan, user.$id, user.email) const res = await api.createSubscriptionCheckout(plan, user.email)
if (res.data?.url) { if (res.data?.url) {
window.location.href = res.data.url window.location.href = res.data.url
} }
@@ -1721,6 +1839,7 @@ export function Settings() {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => { onClick={() => {
setLabelImportErrors([])
const input = document.createElement('input') const input = document.createElement('input')
input.type = 'file' input.type = 'file'
input.accept = 'application/json' input.accept = 'application/json'
@@ -1730,12 +1849,18 @@ export function Settings() {
try { try {
const text = await file.text() const text = await file.text()
const imported = JSON.parse(text) const imported = JSON.parse(text)
if (Array.isArray(imported)) { const { labels, errors } = validateLabelImport(imported, companyLabels)
// TODO: Validate and import labels if (errors.length > 0) {
showMessage('success', `Imported ${imported.length} labels`) setLabelImportErrors(errors)
showMessage('error', 'Fix import errors before saving')
return
} }
setLabelImportErrors([])
setCompanyLabels([...companyLabels, ...labels])
showMessage('success', `Imported ${labels.length} labels`)
} catch { } catch {
showMessage('error', 'Invalid JSON file') showMessage('error', 'Invalid JSON file')
setLabelImportErrors([])
} }
} }
input.click() input.click()
@@ -1755,6 +1880,16 @@ export function Settings() {
Add Label Add Label
</Button> </Button>
</div> </div>
{labelImportErrors.length > 0 && (
<div className="mt-3 w-full rounded-md border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/40 p-3 text-sm text-red-800 dark:text-red-200">
<p className="font-medium mb-1">Import issues</p>
<ul className="list-disc pl-5 space-y-0.5">
{labelImportErrors.map((err, i) => (
<li key={i}>{err}</li>
))}
</ul>
</div>
)}
</div> </div>
{/* Auto-Detection Toggle */} {/* Auto-Detection Toggle */}
@@ -1853,7 +1988,7 @@ export function Settings() {
onClick={async () => { onClick={async () => {
if (!user?.$id || !label.id) return if (!user?.$id || !label.id) return
try { try {
await api.saveCompanyLabel(user.$id, { ...label, enabled: !label.enabled }) await api.saveCompanyLabel({ ...label, enabled: !label.enabled })
setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l)) setCompanyLabels(companyLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
showMessage('success', 'Label updated!') showMessage('success', 'Label updated!')
} catch { } catch {
@@ -1874,7 +2009,7 @@ export function Settings() {
if (!user?.$id || !label.id) return if (!user?.$id || !label.id) return
if (!confirm('Are you sure you want to delete this label?')) return if (!confirm('Are you sure you want to delete this label?')) return
try { try {
await api.deleteCompanyLabel(user.$id, label.id) await api.deleteCompanyLabel(label.id)
setCompanyLabels(companyLabels.filter(l => l.id !== label.id)) setCompanyLabels(companyLabels.filter(l => l.id !== label.id))
showMessage('success', 'Label deleted!') showMessage('success', 'Label deleted!')
} catch { } catch {
@@ -2163,7 +2298,7 @@ export function Settings() {
return return
} }
try { try {
const saved = await api.saveCompanyLabel(user.$id, editingLabel) const saved = await api.saveCompanyLabel(editingLabel)
if (saved.data) { if (saved.data) {
if (editingLabel.id) { if (editingLabel.id) {
setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l)) setCompanyLabels(companyLabels.map(l => l.id === editingLabel.id ? (saved.data || l) : l))
@@ -2235,7 +2370,7 @@ export function Settings() {
onClick={async () => { onClick={async () => {
if (!user?.$id || !label.id) return if (!user?.$id || !label.id) return
try { try {
await api.saveNameLabel(user.$id, user.email, { ...label, enabled: !label.enabled }) await api.saveNameLabel({ ...label, enabled: !label.enabled })
setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l)) setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
showMessage('success', 'Label updated!') showMessage('success', 'Label updated!')
} catch { } catch {
@@ -2256,7 +2391,7 @@ export function Settings() {
if (!user?.$id || !label.id) return if (!user?.$id || !label.id) return
if (!confirm('Delete this name label?')) return if (!confirm('Delete this name label?')) return
try { try {
await api.deleteNameLabel(user.$id, user.email, label.id) await api.deleteNameLabel(label.id)
setNameLabels(nameLabels.filter(l => l.id !== label.id)) setNameLabels(nameLabels.filter(l => l.id !== label.id))
showMessage('success', 'Label deleted!') showMessage('success', 'Label deleted!')
} catch { } catch {
@@ -2383,7 +2518,7 @@ export function Settings() {
return return
} }
try { try {
const saved = await api.saveNameLabel(user.$id, user.email, editingNameLabel) const saved = await api.saveNameLabel(editingNameLabel)
if (saved.data) { if (saved.data) {
if (editingNameLabel.id) { if (editingNameLabel.id) {
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l)) setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
@@ -2466,7 +2601,7 @@ export function Settings() {
onDisconnect={async (accountId) => { onDisconnect={async (accountId) => {
if (!user?.$id) return if (!user?.$id) return
try { try {
const result = await api.disconnectEmailAccount(accountId, user.$id) const result = await api.disconnectEmailAccount(accountId)
if (result.data) { if (result.data) {
setAccounts(accounts.filter(a => a.id !== accountId)) setAccounts(accounts.filter(a => a.id !== accountId))
showMessage('success', 'Account disconnected') showMessage('success', 'Account disconnected')
@@ -2479,7 +2614,7 @@ export function Settings() {
if (!user?.$id) return if (!user?.$id) return
if (!confirm('Are you absolutely sure? This cannot be undone.')) return if (!confirm('Are you absolutely sure? This cannot be undone.')) return
try { try {
const result = await api.deleteAccount(user.$id) const result = await api.deleteAccount()
if (result.data) { if (result.data) {
showMessage('success', 'Account deleted. Redirecting...') showMessage('success', 'Account deleted. Redirecting...')
setTimeout(() => { setTimeout(() => {
@@ -2503,30 +2638,63 @@ export function Settings() {
<CardDescription>Manage your MailFlow subscription</CardDescription> <CardDescription>Manage your MailFlow subscription</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800"> {loading ? (
<div className="flex items-center gap-4"> <p className="text-sm text-slate-500 dark:text-slate-400 py-6">
<div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center"> Loading subscription
<Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" /> </p>
</div> ) : !subscription ? (
<div> <div className="space-y-3 py-2">
<div className="flex items-center gap-2"> <p className="text-sm text-slate-600 dark:text-slate-400">
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">{subscription?.plan || 'Trial'}</h3> Subscription status could not be loaded. Make sure you are signed in and the API is running.
<Badge variant={subscription?.status === 'active' ? 'success' : 'warning'}> </p>
{subscription?.status === 'active' ? 'Active' : 'Trial'} <Button
</Badge> variant="outline"
</div> size="sm"
{subscription?.currentPeriodEnd && ( onClick={async () => {
<p className="text-sm text-slate-500 dark:text-slate-400"> const r = await api.getSubscriptionStatus()
Next billing: {new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')} if (r.data) {
</p> setSubscription(r.data)
)} showMessage('success', 'Subscription loaded')
</div> } else {
showMessage('error', r.error?.message || 'Failed to load subscription')
}
}}
>
Retry
</Button>
</div> </div>
<Button onClick={handleManageSubscription}> ) : (
<ExternalLink className="w-4 h-4 mr-2" /> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-gradient-to-r from-primary-50 to-accent-50 dark:from-primary-900/30 dark:to-accent-900/30 rounded-xl border border-primary-100 dark:border-primary-800">
Manage <div className="flex items-center gap-4">
</Button> <div className="w-14 h-14 rounded-xl bg-white dark:bg-slate-800 shadow-sm flex items-center justify-center shrink-0">
</div> <Crown className="w-7 h-7 text-primary-500 dark:text-primary-400" />
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-bold text-lg text-slate-900 dark:text-slate-100">
{subscriptionTitle(subscription)}
</h3>
{(() => {
const b = subscriptionBadge(subscription)
return b.label ? (
<Badge variant={b.variant}>{b.label}</Badge>
) : null
})()}
</div>
{subscription.currentPeriodEnd && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Next billing:{' '}
{new Date(subscription.currentPeriodEnd).toLocaleDateString('en-US')}
</p>
)}
</div>
</div>
<Button className="shrink-0" onClick={handleManageSubscription}>
<ExternalLink className="w-4 h-4 mr-2" />
Manage
</Button>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -2556,8 +2724,8 @@ export function Settings() {
<span className="text-slate-500 dark:text-slate-400">/month</span> <span className="text-slate-500 dark:text-slate-400">/month</span>
</div> </div>
<ul className="space-y-2 mb-6"> <ul className="space-y-2 mb-6">
{plan.features.map((feature) => ( {plan.features.map((feature, fi) => (
<li key={feature} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400"> <li key={`${plan.id}-${fi}`} className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<Check className="w-4 h-4 text-green-500 dark:text-green-400" /> <Check className="w-4 h-4 text-green-500 dark:text-green-400" />
{feature} {feature}
</li> </li>

View File

@@ -56,7 +56,7 @@ export function Setup() {
if (user?.$id) { if (user?.$id) {
const loadOnboarding = async () => { const loadOnboarding = async () => {
try { try {
const stateRes = await api.getOnboardingStatus(user.$id) const stateRes = await api.getOnboardingStatus()
if (stateRes.data) { if (stateRes.data) {
setOnboardingState(stateRes.data) setOnboardingState(stateRes.data)
@@ -89,7 +89,7 @@ export function Setup() {
if (isFromCheckout && user?.$id) { if (isFromCheckout && user?.$id) {
const checkAccounts = async () => { const checkAccounts = async () => {
try { try {
const accountsRes = await api.getEmailAccounts(user.$id) const accountsRes = await api.getEmailAccounts()
if (accountsRes.data && accountsRes.data.length > 0) { if (accountsRes.data && accountsRes.data.length > 0) {
// User already has accounts connected - redirect to dashboard // User already has accounts connected - redirect to dashboard
navigate('/dashboard?subscription=success&ready=true') navigate('/dashboard?subscription=success&ready=true')
@@ -118,16 +118,16 @@ export function Setup() {
setError(null) setError(null)
try { try {
const response = await api.getOAuthUrl('gmail', user.$id) const response = await api.getOAuthUrl('gmail')
if (response.data?.url) { if (response.data?.url) {
// Track onboarding step before redirect // Track onboarding step before redirect
await api.updateOnboardingStep(user.$id, 'connect', ['connect']) await api.updateOnboardingStep('connect', ['connect'])
window.location.href = response.data.url window.location.href = response.data.url
} else { } else {
setConnectedProvider('gmail') setConnectedProvider('gmail')
setConnectedEmail(user.email) setConnectedEmail(user.email)
setCurrentStep('complete') setCurrentStep('complete')
await api.updateOnboardingStep(user.$id, 'see_results', ['connect']) await api.updateOnboardingStep('see_results', ['connect'])
trackOnboardingStep(user.$id, 'first_rule') trackOnboardingStep(user.$id, 'first_rule')
trackProviderConnected(user.$id, 'gmail') trackProviderConnected(user.$id, 'gmail')
} }
@@ -144,16 +144,16 @@ export function Setup() {
setError(null) setError(null)
try { try {
const response = await api.getOAuthUrl('outlook', user.$id) const response = await api.getOAuthUrl('outlook')
if (response.data?.url) { if (response.data?.url) {
// Track onboarding step before redirect // Track onboarding step before redirect
await api.updateOnboardingStep(user.$id, 'connect', ['connect']) await api.updateOnboardingStep('connect', ['connect'])
window.location.href = response.data.url window.location.href = response.data.url
} else { } else {
setConnectedProvider('outlook') setConnectedProvider('outlook')
setConnectedEmail(user.email) setConnectedEmail(user.email)
setCurrentStep('complete') setCurrentStep('complete')
await api.updateOnboardingStep(user.$id, 'see_results', ['connect']) await api.updateOnboardingStep('see_results', ['connect'])
} }
} catch { } catch {
setError('Outlook connection failed. Please try again.') setError('Outlook connection failed. Please try again.')
@@ -168,12 +168,12 @@ export function Setup() {
setError(null) setError(null)
try { try {
const response = await api.connectDemoAccount(user.$id) const response = await api.connectDemoAccount()
if (response.data) { if (response.data) {
setConnectedProvider('demo') setConnectedProvider('demo')
setConnectedEmail(response.data.email) setConnectedEmail(response.data.email)
setCurrentStep('complete') setCurrentStep('complete')
await api.updateOnboardingStep(user.$id, 'see_results', ['connect']) await api.updateOnboardingStep('see_results', ['connect'])
trackOnboardingStep(user.$id, 'first_rule') trackOnboardingStep(user.$id, 'first_rule')
trackDemoUsed(user.$id) trackDemoUsed(user.$id)
} }
@@ -202,7 +202,7 @@ export function Setup() {
const completedSteps = onboardingState?.completedSteps || [] const completedSteps = onboardingState?.completedSteps || []
if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) { if (onboardingStep && !completedSteps.includes(stepMap[currentStep])) {
const newCompleted = [...completedSteps, stepMap[currentStep]] const newCompleted = [...completedSteps, stepMap[currentStep]]
await api.updateOnboardingStep(user.$id, onboardingStep, newCompleted) await api.updateOnboardingStep(onboardingStep, newCompleted)
setOnboardingState({ setOnboardingState({
onboarding_step: onboardingStep, onboarding_step: onboardingStep,
completedSteps: newCompleted, completedSteps: newCompleted,
@@ -227,7 +227,7 @@ export function Setup() {
setSaving(true) setSaving(true)
try { try {
await api.saveUserPreferences(user.$id, { await api.saveUserPreferences({
vipSenders: [], vipSenders: [],
blockedSenders: [], blockedSenders: [],
customRules: [], customRules: [],
@@ -235,7 +235,7 @@ export function Setup() {
}) })
// Mark onboarding as completed // Mark onboarding as completed
await api.updateOnboardingStep(user.$id, 'completed', ['connect', 'see_results']) await api.updateOnboardingStep('completed', ['connect', 'see_results'])
} catch (err) { } catch (err) {
console.error('Failed to save preferences:', err) console.error('Failed to save preferences:', err)
} finally { } finally {
@@ -248,7 +248,7 @@ export function Setup() {
if (!user?.$id) return if (!user?.$id) return
try { try {
await api.skipOnboarding(user.$id) await api.skipOnboarding()
navigate('/dashboard') navigate('/dashboard')
} catch (err) { } catch (err) {
console.error('Failed to skip onboarding:', err) console.error('Failed to skip onboarding:', err)

View File

@@ -1,27 +1,53 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react(), tailwindcss()], const env = loadEnv(mode, __dirname, '')
resolve: { const appwriteDevOrigin = (env.APPWRITE_DEV_ORIGIN || '').replace(/\/$/, '')
alias: { // 127.0.0.1 avoids Windows localhost → IPv6 (::1) vs backend listening on IPv4-only
'@': path.resolve(__dirname, './src'), const apiDevTarget = (env.VITE_DEV_API_ORIGIN || 'http://127.0.0.1:3000').replace(
/\/$/,
''
)
const proxy: Record<
string,
{ target: string; changeOrigin: boolean; secure?: boolean }
> = {
'/api': {
target: apiDevTarget,
changeOrigin: true,
}, },
}, '/stripe': {
server: { target: apiDevTarget,
port: 5173, changeOrigin: true,
proxy: { },
'/api': { }
target: 'http://localhost:3000',
changeOrigin: true, // Dev: Browser → localhost:5173/v1/* → Appwrite (umgeht CORS, wenn die Console nur z. B. webklar.com erlaubt)
}, if (mode === 'development' && appwriteDevOrigin) {
'/stripe': { proxy['/v1'] = {
target: 'http://localhost:3000', target: appwriteDevOrigin,
changeOrigin: true, changeOrigin: true,
secure: true,
}
}
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
}, },
}, },
}, server: {
port: 5173,
// Wenn 5173 schon belegt ist, nicht still auf einen anderen Port wechseln — sonst öffnet man oft noch die alte URL und bekommt für /api 404.
strictPort: true,
proxy,
},
}
}) })

View File

@@ -76,6 +76,9 @@ export const config = {
autoSchedule: false, // manual only autoSchedule: false, // manual only
}, },
/** Highest product tier (admin comped plan, PLANS key in stripe.mjs). Optional env: TOP_SUBSCRIPTION_PLAN */
topSubscriptionPlan: (process.env.TOP_SUBSCRIPTION_PLAN || 'business').trim().toLowerCase(),
// Admin: comma-separated list of emails with admin rights (e.g. support) // Admin: comma-separated list of emails with admin rights (e.g. support)
adminEmails: (process.env.ADMIN_EMAILS || '') adminEmails: (process.env.ADMIN_EMAILS || '')
.split(',') .split(',')
@@ -87,6 +90,9 @@ export const config = {
webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '', webhookSecret: process.env.GITEA_WEBHOOK_SECRET || '',
webhookAuthToken: process.env.GITEA_WEBHOOK_AUTH_TOKEN || process.env.GITEA_WEBHOOK_SECRET || '', webhookAuthToken: process.env.GITEA_WEBHOOK_AUTH_TOKEN || process.env.GITEA_WEBHOOK_SECRET || '',
}, },
/** HMAC secret for Gmail/Outlook OAuth state (recommended in production) */
oauthStateSecret: process.env.OAUTH_STATE_SECRET || '',
} }
/** /**

View File

@@ -0,0 +1,7 @@
Hello,
We could not process your latest MailFlow payment (invoice {{invoiceId}}).
Please update your payment method in the billing portal to keep your subscription active.
— MailFlow

View File

@@ -0,0 +1,7 @@
Hello,
Your MailFlow subscription has ended on {{endedDate}}.
You can resubscribe anytime from your account settings.
— MailFlow

View File

@@ -0,0 +1,9 @@
Hello,
Your MailFlow subscription was updated.
Plan: {{plan}}
Status: {{status}}
{{periodEndLine}}
— MailFlow

View File

@@ -11,10 +11,11 @@ import { dirname, join } from 'path'
// Config & Middleware // Config & Middleware
import { config, validateConfig } from './config/index.mjs' import { config, validateConfig } from './config/index.mjs'
import { errorHandler, asyncHandler, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs' import { errorHandler, asyncHandler, AppError, NotFoundError, ValidationError, AuthorizationError } from './middleware/errorHandler.mjs'
import { respond } from './utils/response.mjs' import { respond } from './utils/response.mjs'
import { logger, log } from './middleware/logger.mjs' import { logger, log } from './middleware/logger.mjs'
import { limiters } from './middleware/rateLimit.mjs' import { limiters } from './middleware/rateLimit.mjs'
import { requireAuth } from './middleware/auth.mjs'
// Routes // Routes
import oauthRoutes from './routes/oauth.mjs' import oauthRoutes from './routes/oauth.mjs'
@@ -23,6 +24,7 @@ import stripeRoutes from './routes/stripe.mjs'
import apiRoutes from './routes/api.mjs' import apiRoutes from './routes/api.mjs'
import analyticsRoutes from './routes/analytics.mjs' import analyticsRoutes from './routes/analytics.mjs'
import webhookRoutes from './routes/webhook.mjs' import webhookRoutes from './routes/webhook.mjs'
import { startCounterJobs } from './jobs/reset-counters.mjs'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename) const __dirname = dirname(__filename)
@@ -93,21 +95,16 @@ import { userPreferences } from './services/database.mjs'
import { isAdmin } from './config/index.mjs' import { isAdmin } from './config/index.mjs'
/** /**
* GET /api/me?email=xxx * GET /api/me
* Returns current user context (e.g. isAdmin) for the given email. * Returns current user context (JWT). isAdmin from verified email.
*/ */
app.get('/api/me', asyncHandler(async (req, res) => { app.get('/api/me', requireAuth, asyncHandler(async (req, res) => {
const { email } = req.query respond.success(res, { isAdmin: isAdmin(req.appwriteUser.email) })
if (!email || typeof email !== 'string') {
throw new ValidationError('email is required')
}
respond.success(res, { isAdmin: isAdmin(email) })
})) }))
app.get('/api/preferences', asyncHandler(async (req, res) => { app.get('/api/preferences', requireAuth, asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
if (!userId) throw new ValidationError('userId ist erforderlich')
const prefs = await userPreferences.getByUser(userId) const prefs = await userPreferences.getByUser(userId)
respond.success(res, prefs?.preferences || { respond.success(res, prefs?.preferences || {
vipSenders: [], vipSenders: [],
@@ -117,22 +114,40 @@ app.get('/api/preferences', asyncHandler(async (req, res) => {
}) })
})) }))
app.post('/api/preferences', asyncHandler(async (req, res) => { app.post('/api/preferences', requireAuth, asyncHandler(async (req, res) => {
const { userId, ...preferences } = req.body const userId = req.appwriteUser.id
if (!userId) throw new ValidationError('userId ist erforderlich') const { ...preferences } = req.body
await userPreferences.upsert(userId, preferences) await userPreferences.upsert(userId, preferences)
respond.success(res, null, 'Einstellungen gespeichert') 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 /api/preferences/ai-control
* Get AI Control settings * Get AI Control settings
*/ */
app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => { app.get('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
if (!userId) throw new ValidationError('userId is required')
const prefs = await userPreferences.getByUser(userId) const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults() const preferences = prefs?.preferences || userPreferences.getDefaults()
@@ -148,10 +163,10 @@ app.get('/api/preferences/ai-control', asyncHandler(async (req, res) => {
* POST /api/preferences/ai-control * POST /api/preferences/ai-control
* Save AI Control settings * Save AI Control settings
*/ */
app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => { app.post('/api/preferences/ai-control', requireAuth, asyncHandler(async (req, res) => {
const { userId, enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body const userId = req.appwriteUser.id
if (!userId) throw new ValidationError('userId is required') const { enabledCategories, categoryActions, autoDetectCompanies, cleanup } = req.body
const updates = {} const updates = {}
if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories if (enabledCategories !== undefined) updates.enabledCategories = enabledCategories
if (categoryActions !== undefined) updates.categoryActions = categoryActions if (categoryActions !== undefined) updates.categoryActions = categoryActions
@@ -166,10 +181,9 @@ app.post('/api/preferences/ai-control', asyncHandler(async (req, res) => {
* GET /api/preferences/company-labels * GET /api/preferences/company-labels
* Get company labels * Get company labels
*/ */
app.get('/api/preferences/company-labels', asyncHandler(async (req, res) => { app.get('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
if (!userId) throw new ValidationError('userId is required')
const prefs = await userPreferences.getByUser(userId) const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults() const preferences = prefs?.preferences || userPreferences.getDefaults()
@@ -180,9 +194,9 @@ app.get('/api/preferences/company-labels', asyncHandler(async (req, res) => {
* POST /api/preferences/company-labels * POST /api/preferences/company-labels
* Save/Update company label * Save/Update company label
*/ */
app.post('/api/preferences/company-labels', asyncHandler(async (req, res) => { app.post('/api/preferences/company-labels', requireAuth, asyncHandler(async (req, res) => {
const { userId, companyLabel } = req.body const userId = req.appwriteUser.id
if (!userId) throw new ValidationError('userId is required') const { companyLabel } = req.body
if (!companyLabel) throw new ValidationError('companyLabel is required') if (!companyLabel) throw new ValidationError('companyLabel is required')
const prefs = await userPreferences.getByUser(userId) const prefs = await userPreferences.getByUser(userId)
@@ -211,10 +225,9 @@ app.post('/api/preferences/company-labels', asyncHandler(async (req, res) => {
* DELETE /api/preferences/company-labels/:id * DELETE /api/preferences/company-labels/:id
* Delete company label * Delete company label
*/ */
app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res) => { app.delete('/api/preferences/company-labels/:id', requireAuth, asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
const { id } = req.params const { id } = req.params
if (!userId) throw new ValidationError('userId is required')
if (!id) throw new ValidationError('label id is required') if (!id) throw new ValidationError('label id is required')
const prefs = await userPreferences.getByUser(userId) const prefs = await userPreferences.getByUser(userId)
@@ -230,12 +243,10 @@ app.delete('/api/preferences/company-labels/:id', asyncHandler(async (req, res)
* GET /api/preferences/name-labels * GET /api/preferences/name-labels
* Get name labels (worker labels). Admin only. * Get name labels (worker labels). Admin only.
*/ */
app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => { app.get('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => {
const { userId, email } = req.query if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
const userId = req.appwriteUser.id
const prefs = await userPreferences.getByUser(userId) const prefs = await userPreferences.getByUser(userId)
const preferences = prefs?.preferences || userPreferences.getDefaults() const preferences = prefs?.preferences || userPreferences.getDefaults()
respond.success(res, preferences.nameLabels || []) respond.success(res, preferences.nameLabels || [])
@@ -245,11 +256,11 @@ app.get('/api/preferences/name-labels', asyncHandler(async (req, res) => {
* POST /api/preferences/name-labels * POST /api/preferences/name-labels
* Save/Update name label (worker). Admin only. * Save/Update name label (worker). Admin only.
*/ */
app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => { app.post('/api/preferences/name-labels', requireAuth, asyncHandler(async (req, res) => {
const { userId, email, nameLabel } = req.body if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required') const userId = req.appwriteUser.id
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels') const { nameLabel } = req.body
if (!nameLabel) throw new ValidationError('nameLabel is required') if (!nameLabel) throw new ValidationError('nameLabel is required')
const prefs = await userPreferences.getByUser(userId) const prefs = await userPreferences.getByUser(userId)
@@ -274,12 +285,11 @@ app.post('/api/preferences/name-labels', asyncHandler(async (req, res) => {
* DELETE /api/preferences/name-labels/:id * DELETE /api/preferences/name-labels/:id
* Delete name label. Admin only. * Delete name label. Admin only.
*/ */
app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) => { app.delete('/api/preferences/name-labels/:id', requireAuth, asyncHandler(async (req, res) => {
const { userId, email } = req.query if (!isAdmin(req.appwriteUser.email)) throw new AuthorizationError('Admin access required for name labels')
const userId = req.appwriteUser.id
const { id } = req.params const { id } = req.params
if (!userId) throw new ValidationError('userId is required')
if (!email || typeof email !== 'string') throw new ValidationError('email is required')
if (!isAdmin(email)) throw new AuthorizationError('Admin access required for name labels')
if (!id) throw new ValidationError('label id is required') if (!id) throw new ValidationError('label id is required')
const prefs = await userPreferences.getByUser(userId) const prefs = await userPreferences.getByUser(userId)
@@ -292,14 +302,33 @@ app.delete('/api/preferences/name-labels/:id', asyncHandler(async (req, res) =>
// Legacy Stripe webhook endpoint // Legacy Stripe webhook endpoint
app.use('/stripe', stripeRoutes) app.use('/stripe', stripeRoutes)
// 404 handler for API routes // Unmatched /api → JSON 404 (Express 4 treats '/api/*' as a literal path, not a wildcard)
app.use('/api/*', (req, res, next) => { app.use((req, res, next) => {
const pathOnly = req.originalUrl.split('?')[0]
if (!pathOnly.startsWith('/api')) {
return next()
}
next(new NotFoundError('Endpoint')) next(new NotFoundError('Endpoint'))
}) })
// SPA fallback for non-API routes // SPA fallback: never send index.html for /api (avoids 404/HTML when public/index.html is missing)
app.get('*', (req, res) => { app.get('*', (req, res, next) => {
res.sendFile(join(__dirname, '..', 'public', 'index.html')) const pathOnly = req.originalUrl.split('?')[0]
if (pathOnly.startsWith('/api')) {
return next(new NotFoundError('Endpoint'))
}
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'
)
)
}
})
}) })
// Global error handler (must be last) // Global error handler (must be last)
@@ -346,6 +375,7 @@ server = app.listen(config.port, () => {
console.log(` 🌐 API: http://localhost:${config.port}/api`) console.log(` 🌐 API: http://localhost:${config.port}/api`)
console.log(` 💚 Health: http://localhost:${config.port}/api/health`) console.log(` 💚 Health: http://localhost:${config.port}/api/health`)
console.log('') console.log('')
startCounterJobs()
}) })
export default app export default app

View File

@@ -0,0 +1,39 @@
/**
* Scheduled counter resets (UTC).
*/
import cron from 'node-cron'
import { emailStats } from '../services/database.mjs'
import { log } from '../middleware/logger.mjs'
export function startCounterJobs() {
cron.schedule(
'0 0 * * *',
async () => {
const t = new Date().toISOString()
try {
const n = await emailStats.resetDaily()
log.info(`[cron] resetDaily at ${t} — updated ${n} email_stats documents`)
} catch (e) {
log.error('[cron] resetDaily failed', { error: e.message })
}
},
{ timezone: 'UTC' }
)
cron.schedule(
'0 0 1 * *',
async () => {
const t = new Date().toISOString()
try {
const n = await emailStats.resetWeekly()
log.info(`[cron] resetWeekly at ${t} — updated ${n} email_stats documents`)
} catch (e) {
log.error('[cron] resetWeekly failed', { error: e.message })
}
},
{ timezone: 'UTC' }
)
log.success('Counter cron jobs scheduled (daily 00:00 UTC, monthly week reset 1st 00:00 UTC)')
}

View File

@@ -0,0 +1,58 @@
/**
* Appwrite JWT verification for user-scoped API routes.
*/
import { Client, Account } from 'node-appwrite'
import { config } from '../config/index.mjs'
import { AuthenticationError } from './errorHandler.mjs'
/**
* Verify Authorization: Bearer <jwt> and attach Appwrite user to req.appwriteUser
*/
export function requireAuth(req, res, next) {
;(async () => {
try {
const header = req.headers.authorization || ''
const m = /^Bearer\s+(.+)$/i.exec(header)
if (!m?.[1]) {
throw new AuthenticationError('Authorization Bearer token required')
}
const jwt = m[1].trim()
const client = new Client()
.setEndpoint(config.appwrite.endpoint)
.setProject(config.appwrite.projectId)
.setJWT(jwt)
const account = new Account(client)
const user = await account.get()
if (!user || !user.$id) {
throw new AuthenticationError('Ungültige Appwrite-Sitzung')
}
req.appwriteUser = {
id: user.$id,
email: user.email || '',
name: user.name || '',
}
next()
} catch (err) {
if (err instanceof AuthenticationError) {
next(err)
return
}
next(new AuthenticationError(err.message || 'Invalid or expired session'))
}
})()
}
/**
* Skip auth for email provider inbound webhooks only.
*/
export function requireAuthUnlessEmailWebhook(req, res, next) {
const p = req.path || ''
if (p === '/webhook/gmail' || p === '/webhook/outlook') {
return next()
}
return requireAuth(req, res, next)
}

View File

@@ -3,6 +3,8 @@
* Catches all errors and returns consistent JSON responses * Catches all errors and returns consistent JSON responses
*/ */
import { AppwriteException } from 'node-appwrite'
export class AppError extends Error { export class AppError extends Error {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') { constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
super(message) super(message)
@@ -56,11 +58,28 @@ export function errorHandler(err, req, res, next) {
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
}) })
// Default error values // Default error values (AppwriteException uses numeric err.code — do not reuse as JSON "code" string)
let statusCode = err.statusCode || 500 let statusCode =
let code = err.code || 'INTERNAL_ERROR' typeof err.statusCode === 'number' ? err.statusCode : undefined
let code = typeof err.code === 'string' && err.code ? err.code : 'INTERNAL_ERROR'
let message = err.message || 'Ein Fehler ist aufgetreten' let message = err.message || 'Ein Fehler ist aufgetreten'
if (
err instanceof AppwriteException &&
typeof err.code === 'number' &&
err.code >= 400 &&
err.code < 600
) {
statusCode = err.code
code = err.type || 'APPWRITE_ERROR'
message = err.message || message
err.isOperational = true
}
if (statusCode === undefined) {
statusCode = 500
}
// Handle specific error types // Handle specific error types
if (err.name === 'ValidationError') { if (err.name === 'ValidationError') {
statusCode = 400 statusCode = 400

View File

@@ -4,6 +4,7 @@
*/ */
import { RateLimitError } from './errorHandler.mjs' import { RateLimitError } from './errorHandler.mjs'
import { isAdmin } from '../config/index.mjs'
// In-memory store for rate limiting (use Redis in production) // In-memory store for rate limiting (use Redis in production)
const requestCounts = new Map() const requestCounts = new Map()
@@ -25,6 +26,7 @@ setInterval(() => {
* @param {number} options.max - Max requests per window * @param {number} options.max - Max requests per window
* @param {string} options.message - Error message * @param {string} options.message - Error message
* @param {Function} options.keyGenerator - Function to generate unique key * @param {Function} options.keyGenerator - Function to generate unique key
* @param {Function} options.skip - If (req) => true, do not count this request
*/ */
export function rateLimit(options = {}) { export function rateLimit(options = {}) {
const { const {
@@ -32,9 +34,14 @@ export function rateLimit(options = {}) {
max = 100, max = 100,
message = 'Zu viele Anfragen. Bitte versuche es später erneut.', message = 'Zu viele Anfragen. Bitte versuche es später erneut.',
keyGenerator = (req) => req.ip, keyGenerator = (req) => req.ip,
skip = () => false,
} = options } = options
return (req, res, next) => { return (req, res, next) => {
if (skip(req)) {
return next()
}
const key = keyGenerator(req) const key = keyGenerator(req)
const now = Date.now() const now = Date.now()
@@ -80,11 +87,12 @@ export const limiters = {
message: 'Zu viele Anmeldeversuche. Bitte warte 15 Minuten.', message: 'Zu viele Anmeldeversuche. Bitte warte 15 Minuten.',
}), }),
// Limit for email sorting (expensive operation) // Limit for email sorting (expensive operation); ADMIN_EMAILS (isAdmin) bypass
emailSort: rateLimit({ emailSort: rateLimit({
windowMs: 60000, windowMs: 60000,
max: 30, // Erhöht für Entwicklung max: 30, // Erhöht für Entwicklung
message: 'E-Mail-Sortierung ist limitiert. Bitte warte eine Minute.', message: 'E-Mail-Sortierung ist limitiert. Bitte warte eine Minute.',
skip: (req) => isAdmin(req.appwriteUser?.email),
}), }),
// Limit for AI operations // Limit for AI operations

View File

@@ -1,11 +1,11 @@
{ {
"name": "email-sorter-server", "name": "mailflow-server",
"version": "2.0.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "email-sorter-server", "name": "mailflow-server",
"version": "2.0.0", "version": "2.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -18,7 +18,9 @@
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"imapflow": "^1.2.8", "imapflow": "^1.2.8",
"node-appwrite": "^14.1.0", "node-appwrite": "^14.1.0",
"stripe": "^17.4.0" "node-cron": "^4.2.1",
"nodemailer": "^8.0.4",
"stripe": "^17.7.0"
}, },
"devDependencies": { "devDependencies": {
"jsdom": "^27.4.0" "jsdom": "^27.4.0"
@@ -1119,6 +1121,15 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/imapflow/node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1448,6 +1459,15 @@
"node-fetch-native-with-agent": "1.7.2" "node-fetch-native-with-agent": "1.7.2"
} }
}, },
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -1497,9 +1517,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "7.0.13", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"

View File

@@ -41,7 +41,9 @@
"googleapis": "^144.0.0", "googleapis": "^144.0.0",
"imapflow": "^1.2.8", "imapflow": "^1.2.8",
"node-appwrite": "^14.1.0", "node-appwrite": "^14.1.0",
"stripe": "^17.4.0" "node-cron": "^4.2.1",
"nodemailer": "^8.0.4",
"stripe": "^17.7.0"
}, },
"devDependencies": { "devDependencies": {
"jsdom": "^27.4.0" "jsdom": "^27.4.0"

View File

@@ -8,9 +8,12 @@ import { asyncHandler, ValidationError } from '../middleware/errorHandler.mjs'
import { respond } from '../utils/response.mjs' import { respond } from '../utils/response.mjs'
import { db, Collections } from '../services/database.mjs' import { db, Collections } from '../services/database.mjs'
import { log } from '../middleware/logger.mjs' import { log } from '../middleware/logger.mjs'
import { requireAuth } from '../middleware/auth.mjs'
const router = express.Router() const router = express.Router()
router.use(requireAuth)
// Whitelist of allowed event types // Whitelist of allowed event types
const ALLOWED_EVENT_TYPES = [ const ALLOWED_EVENT_TYPES = [
'page_view', 'page_view',
@@ -79,7 +82,6 @@ function stripPII(metadata) {
router.post('/track', asyncHandler(async (req, res) => { router.post('/track', asyncHandler(async (req, res) => {
const { const {
type, type,
userId,
tracking, tracking,
metadata, metadata,
timestamp, timestamp,
@@ -88,6 +90,8 @@ router.post('/track', asyncHandler(async (req, res) => {
sessionId, sessionId,
} = req.body } = req.body
const userId = req.appwriteUser.id
// Validate event type // Validate event type
if (!type || !ALLOWED_EVENT_TYPES.includes(type)) { if (!type || !ALLOWED_EVENT_TYPES.includes(type)) {
throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`) throw new ValidationError(`Invalid event type. Allowed: ${ALLOWED_EVENT_TYPES.join(', ')}`)

View File

@@ -11,6 +11,7 @@ import { products, questions, submissions, orders, onboardingState, emailAccount
import Stripe from 'stripe' import Stripe from 'stripe'
import { config } from '../config/index.mjs' import { config } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs' import { log } from '../middleware/logger.mjs'
import { requireAuth } from '../middleware/auth.mjs'
const router = express.Router() const router = express.Router()
const stripe = new Stripe(config.stripe.secretKey) const stripe = new Stripe(config.stripe.secretKey)
@@ -177,13 +178,9 @@ router.get('/config', (req, res) => {
* Get current onboarding state * Get current onboarding state
*/ */
router.get('/onboarding/status', router.get('/onboarding/status',
validate({ requireAuth,
query: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
const state = await onboardingState.getByUser(userId) const state = await onboardingState.getByUser(userId)
respond.success(res, state) respond.success(res, state)
}) })
@@ -194,15 +191,16 @@ router.get('/onboarding/status',
* Update onboarding step progress * Update onboarding step progress
*/ */
router.post('/onboarding/step', router.post('/onboarding/step',
requireAuth,
validate({ validate({
body: { body: {
userId: [rules.required('userId')],
step: [rules.required('step')], step: [rules.required('step')],
completedSteps: [rules.isArray('completedSteps')], completedSteps: [rules.isArray('completedSteps')],
}, },
}), }),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId, step, completedSteps = [] } = req.body const userId = req.appwriteUser.id
const { step, completedSteps = [] } = req.body
await onboardingState.updateStep(userId, step, completedSteps) await onboardingState.updateStep(userId, step, completedSteps)
respond.success(res, { step, completedSteps }) respond.success(res, { step, completedSteps })
}) })
@@ -213,13 +211,9 @@ router.post('/onboarding/step',
* Skip onboarding * Skip onboarding
*/ */
router.post('/onboarding/skip', router.post('/onboarding/skip',
validate({ requireAuth,
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.body const userId = req.appwriteUser.id
await onboardingState.skip(userId) await onboardingState.skip(userId)
respond.success(res, { skipped: true }) respond.success(res, { skipped: true })
}) })
@@ -230,13 +224,9 @@ router.post('/onboarding/skip',
* Resume onboarding * Resume onboarding
*/ */
router.post('/onboarding/resume', router.post('/onboarding/resume',
validate({ requireAuth,
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.body const userId = req.appwriteUser.id
await onboardingState.resume(userId) await onboardingState.resume(userId)
const state = await onboardingState.getByUser(userId) const state = await onboardingState.getByUser(userId)
respond.success(res, state) respond.success(res, state)
@@ -248,13 +238,9 @@ router.post('/onboarding/resume',
* Delete all user data and account * Delete all user data and account
*/ */
router.delete('/account/delete', router.delete('/account/delete',
validate({ requireAuth,
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.body const userId = req.appwriteUser.id
log.info(`Account deletion requested for user ${userId}`) log.info(`Account deletion requested for user ${userId}`)
@@ -301,7 +287,7 @@ router.delete('/account/delete',
} }
// Delete subscription // Delete subscription
const subscription = await subscriptions.getByUser(userId) const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
if (subscription && subscription.$id) { if (subscription && subscription.$id) {
try { try {
await db.delete(Collections.SUBSCRIPTIONS, subscription.$id) await db.delete(Collections.SUBSCRIPTIONS, subscription.$id)
@@ -344,13 +330,9 @@ router.delete('/account/delete',
* Get or create referral code for user * Get or create referral code for user
*/ */
router.get('/referrals/code', router.get('/referrals/code',
validate({ requireAuth,
query: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
const referral = await referrals.getOrCreateCode(userId) const referral = await referrals.getOrCreateCode(userId)
respond.success(res, { respond.success(res, {
referralCode: referral.referralCode, referralCode: referral.referralCode,
@@ -364,14 +346,15 @@ router.get('/referrals/code',
* Track a referral (when new user signs up with referral code) * Track a referral (when new user signs up with referral code)
*/ */
router.post('/referrals/track', router.post('/referrals/track',
requireAuth,
validate({ validate({
body: { body: {
userId: [rules.required('userId')],
referralCode: [rules.required('referralCode')], referralCode: [rules.required('referralCode')],
}, },
}), }),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId, referralCode } = req.body const userId = req.appwriteUser.id
const { referralCode } = req.body
// Find referrer by code // Find referrer by code
const referrer = await referrals.getByCode(referralCode) const referrer = await referrals.getByCode(referralCode)

View File

@@ -9,11 +9,15 @@ import { validate, rules } from '../middleware/validate.mjs'
import { limiters } from '../middleware/rateLimit.mjs' import { limiters } from '../middleware/rateLimit.mjs'
import { respond } from '../utils/response.mjs' import { respond } from '../utils/response.mjs'
import { emailAccounts, emailStats, emailDigests, userPreferences, emailUsage, subscriptions } from '../services/database.mjs' import { emailAccounts, emailStats, emailDigests, userPreferences, emailUsage, subscriptions } from '../services/database.mjs'
import { config, features } from '../config/index.mjs' import { config, features, isAdmin } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs' import { log } from '../middleware/logger.mjs'
import { requireAuthUnlessEmailWebhook } from '../middleware/auth.mjs'
import { encryptImapSecret, decryptImapSecret } from '../utils/crypto.mjs'
const router = express.Router() const router = express.Router()
router.use(requireAuthUnlessEmailWebhook)
// Lazy load heavy services // Lazy load heavy services
let gmailServiceClass = null let gmailServiceClass = null
let outlookServiceClass = null let outlookServiceClass = null
@@ -77,13 +81,13 @@ const DEMO_EMAILS = [
router.post('/connect', router.post('/connect',
validate({ validate({
body: { body: {
userId: [rules.required('userId')],
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])], provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo', 'imap'])],
email: [rules.required('email'), rules.email()], email: [rules.required('email'), rules.email()],
}, },
}), }),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId, provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body const userId = req.appwriteUser.id
const { provider, email, accessToken, refreshToken, expiresAt, password, imapHost, imapPort, imapSecure } = req.body
// IMAP: require password (or accessToken as password) // IMAP: require password (or accessToken as password)
if (provider === 'imap') { if (provider === 'imap') {
@@ -125,11 +129,12 @@ router.post('/connect',
} }
// Create account // Create account
const rawImapSecret = provider === 'imap' ? (password || accessToken) : ''
const accountData = { const accountData = {
userId, userId,
provider, provider,
email, email,
accessToken: provider === 'imap' ? (password || accessToken) : (accessToken || ''), accessToken: provider === 'imap' ? encryptImapSecret(rawImapSecret) : (accessToken || ''),
refreshToken: provider === 'imap' ? '' : (refreshToken || ''), refreshToken: provider === 'imap' ? '' : (refreshToken || ''),
expiresAt: provider === 'imap' ? 0 : (expiresAt || 0), expiresAt: provider === 'imap' ? 0 : (expiresAt || 0),
isActive: true, isActive: true,
@@ -157,13 +162,8 @@ router.post('/connect',
* Connect a demo email account for testing * Connect a demo email account for testing
*/ */
router.post('/connect-demo', router.post('/connect-demo',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.body const userId = req.appwriteUser.id
const demoEmail = `demo-${userId.slice(0, 8)}@mailflow.demo` const demoEmail = `demo-${userId.slice(0, 8)}@mailflow.demo`
// Check if demo account already exists // Check if demo account already exists
@@ -207,11 +207,7 @@ router.post('/connect-demo',
* Get user's connected email accounts * Get user's connected email accounts
*/ */
router.get('/accounts', asyncHandler(async (req, res) => { router.get('/accounts', asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
if (!userId) {
throw new ValidationError('userId is required')
}
const accounts = await emailAccounts.getByUser(userId) const accounts = await emailAccounts.getByUser(userId)
@@ -234,11 +230,7 @@ router.get('/accounts', asyncHandler(async (req, res) => {
*/ */
router.delete('/accounts/:accountId', asyncHandler(async (req, res) => { router.delete('/accounts/:accountId', asyncHandler(async (req, res) => {
const { accountId } = req.params const { accountId } = req.params
const { userId } = req.query const userId = req.appwriteUser.id
if (!userId) {
throw new ValidationError('userId is required')
}
// Verify ownership // Verify ownership
const account = await emailAccounts.get(accountId) const account = await emailAccounts.get(accountId)
@@ -259,11 +251,7 @@ router.delete('/accounts/:accountId', asyncHandler(async (req, res) => {
* Get email sorting statistics * Get email sorting statistics
*/ */
router.get('/stats', asyncHandler(async (req, res) => { router.get('/stats', asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
if (!userId) {
throw new ValidationError('userId is required')
}
const stats = await emailStats.getByUser(userId) const stats = await emailStats.getByUser(userId)
@@ -299,19 +287,20 @@ router.post('/sort',
limiters.emailSort, limiters.emailSort,
validate({ validate({
body: { body: {
userId: [rules.required('userId')],
accountId: [rules.required('accountId')], accountId: [rules.required('accountId')],
}, },
}), }),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId, accountId, maxEmails = 500, processAll = true } = req.body const userId = req.appwriteUser.id
const { accountId, maxEmails = 500, processAll = true } = req.body
// Check subscription status and free tier limits // Check subscription status and free tier limits
const subscription = await subscriptions.getByUser(userId) const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
const isFreeTier = subscription?.isFreeTier || false const isFreeTier = subscription?.isFreeTier || false
const adminUser = isAdmin(req.appwriteUser?.email)
// Check free tier limit
if (isFreeTier) { // Check free tier limit (admins: unlimited)
if (isFreeTier && !adminUser) {
const usage = await emailUsage.getUsage(userId) const usage = await emailUsage.getUsage(userId)
const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth const limit = subscription?.emailsLimit || config.freeTier.emailsPerMonth
@@ -875,7 +864,7 @@ router.post('/sort',
port: account.imapPort != null ? account.imapPort : 993, port: account.imapPort != null ? account.imapPort : 993,
secure: account.imapSecure !== false, secure: account.imapSecure !== false,
user: account.email, user: account.email,
password: account.accessToken, password: decryptImapSecret(account.accessToken),
}) })
try { try {
@@ -1013,8 +1002,8 @@ router.post('/sort',
// Update last sync // Update last sync
await emailAccounts.updateLastSync(accountId) await emailAccounts.updateLastSync(accountId)
// Update email usage (for free tier tracking) // Update email usage (for free tier tracking; admins are "business", skip counter)
if (isFreeTier) { if (isFreeTier && !adminUser) {
await emailUsage.increment(userId, sortedCount) await emailUsage.increment(userId, sortedCount)
} }
@@ -1202,18 +1191,18 @@ router.post('/sort-demo', asyncHandler(async (req, res) => {
})) }))
/** /**
* POST /api/email/cleanup * POST /api/email/cleanup/mailflow-labels
* Cleanup old MailFlow labels from Gmail * Cleanup old MailFlow labels from Gmail (legacy label names)
*/ */
router.post('/cleanup', router.post('/cleanup/mailflow-labels',
validate({ validate({
body: { body: {
userId: [rules.required('userId')],
accountId: [rules.required('accountId')], accountId: [rules.required('accountId')],
}, },
}), }),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId, accountId } = req.body const userId = req.appwriteUser.id
const { accountId } = req.body
const account = await emailAccounts.get(accountId) const account = await emailAccounts.get(accountId)
@@ -1246,11 +1235,7 @@ router.post('/cleanup',
* Get today's sorting digest summary * Get today's sorting digest summary
*/ */
router.get('/digest', asyncHandler(async (req, res) => { router.get('/digest', asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
if (!userId) {
throw new ValidationError('userId is required')
}
const digest = await emailDigests.getByUserToday(userId) const digest = await emailDigests.getByUserToday(userId)
@@ -1285,13 +1270,10 @@ router.get('/digest', asyncHandler(async (req, res) => {
* Get digest history for the last N days * Get digest history for the last N days
*/ */
router.get('/digest/history', asyncHandler(async (req, res) => { router.get('/digest/history', asyncHandler(async (req, res) => {
const { userId, days = 7 } = req.query const userId = req.appwriteUser.id
const days = req.query.days ?? 7
if (!userId) { const digests = await emailDigests.getByUserRecent(userId, parseInt(String(days), 10))
throw new ValidationError('userId is required')
}
const digests = await emailDigests.getByUserRecent(userId, parseInt(days))
// Calculate totals // Calculate totals
const totals = { const totals = {
@@ -1333,6 +1315,77 @@ router.get('/categories', asyncHandler(async (req, res) => {
respond.success(res, formattedCategories) respond.success(res, formattedCategories)
})) }))
/**
* GET /api/email/:accountId/cleanup/preview
* Dry-run: messages that would be affected by cleanup settings (no mutations).
*
* curl examples:
* curl -s -H "Authorization: Bearer YOUR_JWT" "http://localhost:3000/api/email/ACCOUNT_DOC_ID/cleanup/preview"
*/
router.get('/:accountId/cleanup/preview', asyncHandler(async (req, res) => {
const userId = req.appwriteUser.id
const { accountId } = req.params
const account = await emailAccounts.get(accountId)
if (account.userId !== userId) {
throw new AuthorizationError('No permission for this account')
}
const prefs = await userPreferences.getByUser(userId)
const cleanup = prefs?.preferences?.cleanup || userPreferences.getDefaults().cleanup
const maxList = cleanup.safety?.maxDeletesPerRun ?? 100
const messages = []
if (cleanup.readItems?.enabled) {
const readList = await listReadCleanupPreviewMessages(account, cleanup.readItems.gracePeriodDays, maxList)
for (const m of readList) {
if (messages.length >= maxList) break
messages.push({ ...m, reason: 'read' })
}
}
if (cleanup.promotions?.enabled && messages.length < maxList) {
const promoList = await listPromotionCleanupPreviewMessages(
account,
cleanup.promotions.deleteAfterDays,
cleanup.promotions.matchCategoriesOrLabels || [],
maxList - messages.length
)
for (const m of promoList) {
if (messages.length >= maxList) break
messages.push({ ...m, reason: 'promotion' })
}
}
respond.success(res, { messages, count: messages.length })
}))
/**
* GET /api/email/:accountId/cleanup/status
*
* curl examples:
* curl -s -H "Authorization: Bearer YOUR_JWT" "http://localhost:3000/api/email/ACCOUNT_DOC_ID/cleanup/status"
*/
router.get('/:accountId/cleanup/status', asyncHandler(async (req, res) => {
const userId = req.appwriteUser.id
const { accountId } = req.params
const account = await emailAccounts.get(accountId)
if (account.userId !== userId) {
throw new AuthorizationError('No permission for this account')
}
const prefs = await userPreferences.getByUser(userId)
const meta = prefs?.preferences?.cleanupMeta || {}
respond.success(res, {
lastRun: meta.lastRun,
lastRunCounts: meta.lastRunCounts,
lastErrors: meta.lastErrors,
})
}))
/** /**
* POST /api/email/webhook/gmail * POST /api/email/webhook/gmail
* Gmail push notification webhook * Gmail push notification webhook
@@ -1380,10 +1433,10 @@ router.post('/webhook/outlook', asyncHandler(async (req, res) => {
* Can be called manually or by cron job * Can be called manually or by cron job
*/ */
router.post('/cleanup', asyncHandler(async (req, res) => { router.post('/cleanup', asyncHandler(async (req, res) => {
const { userId } = req.body // Optional: only process this user, otherwise all users const userId = req.appwriteUser.id
log.info('Cleanup job started', { userId: userId || 'all' }) log.info('Cleanup job started', { userId })
const results = { const results = {
usersProcessed: 0, usersProcessed: 0,
emailsProcessed: { emailsProcessed: {
@@ -1394,72 +1447,60 @@ router.post('/cleanup', asyncHandler(async (req, res) => {
} }
try { try {
// Get all users with cleanup enabled const prefs = await userPreferences.getByUser(userId)
let usersToProcess = [] if (!prefs?.preferences?.cleanup?.enabled) {
return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
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 const accounts = await emailAccounts.getByUser(userId)
if (userId) { if (!accounts || accounts.length === 0) {
const prefs = await userPreferences.getByUser(userId) return respond.success(res, { ...results, message: 'No email accounts found' })
if (!prefs?.preferences?.cleanup?.enabled) { }
return respond.success(res, { ...results, message: 'Cleanup not enabled for this user' })
}
const accounts = await emailAccounts.getByUser(userId) for (const account of accounts) {
if (!accounts || accounts.length === 0) { if (!account.isActive || !account.accessToken) continue
return respond.success(res, { ...results, message: 'No email accounts found' })
}
for (const account of accounts) { try {
if (!account.isActive || !account.accessToken) continue const cleanup = prefs.preferences.cleanup
try { if (cleanup.readItems?.enabled) {
const cleanup = prefs.preferences.cleanup const readItemsCount = await processReadItemsCleanup(
account,
// Read Items Cleanup cleanup.readItems.action,
if (cleanup.readItems?.enabled) { cleanup.readItems.gracePeriodDays
const readItemsCount = await processReadItemsCleanup( )
account, results.emailsProcessed.readItems += readItemsCount
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 })
} }
if (cleanup.promotions?.enabled) {
const promotionsCount = await processPromotionsCleanup(
account,
cleanup.promotions.action,
cleanup.promotions.deleteAfterDays,
cleanup.promotions.matchCategoriesOrLabels || []
)
results.emailsProcessed.promotions += promotionsCount
}
results.usersProcessed = 1
} catch (error) {
log.error(`Cleanup failed for account ${account.email}`, { error: error.message })
results.errors.push({ userId, accountId: account.$id, error: error.message })
} }
} }
const lastRun = new Date().toISOString()
await userPreferences.upsert(userId, {
cleanupMeta: {
lastRun,
lastRunCounts: {
readItems: results.emailsProcessed.readItems,
promotions: results.emailsProcessed.promotions,
},
lastErrors: results.errors.map((e) => e.error),
},
})
log.success('Cleanup job completed', results) log.success('Cleanup job completed', results)
respond.success(res, results, 'Cleanup completed') respond.success(res, results, 'Cleanup completed')
} catch (error) { } catch (error) {
@@ -1607,4 +1648,98 @@ async function processPromotionsCleanup(account, action, deleteAfterDays, matchC
return processedCount return processedCount
} }
async function listReadCleanupPreviewMessages(account, gracePeriodDays, cap) {
const out = []
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays)
const before = `${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
try {
if (account.provider === 'gmail') {
const gmail = await getGmailService(account.accessToken, account.refreshToken)
const query = `-is:unread before:${before}`
const response = await gmail.gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: Math.min(cap, 500),
})
const ids = (response.data.messages || []).map((m) => m.id).slice(0, cap)
const emails = await gmail.batchGetEmails(ids)
for (const email of emails) {
out.push({
id: email.id,
subject: email.headers?.subject || '',
from: email.headers?.from || '',
date: email.headers?.date || email.internalDate || '',
})
}
} else if (account.provider === 'outlook') {
const outlook = await getOutlookService(account.accessToken)
const filter = `isRead eq true and receivedDateTime lt ${cutoffDate.toISOString()}`
const data = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=${Math.min(cap, 500)}`)
for (const message of data.value || []) {
out.push({
id: message.id,
subject: message.subject || '',
from: message.from?.emailAddress?.address || '',
date: message.receivedDateTime || '',
})
if (out.length >= cap) break
}
}
} catch (err) {
log.warn('listReadCleanupPreviewMessages failed', { error: err.message })
}
return out.slice(0, cap)
}
async function listPromotionCleanupPreviewMessages(account, deleteAfterDays, matchCategories, cap) {
const out = []
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - deleteAfterDays)
const before = `${cutoffDate.getFullYear()}/${String(cutoffDate.getMonth() + 1).padStart(2, '0')}/${String(cutoffDate.getDate()).padStart(2, '0')}`
try {
if (account.provider === 'gmail' && matchCategories.length > 0) {
const gmail = await getGmailService(account.accessToken, account.refreshToken)
const labelQueries = matchCategories.map((cat) => `label:MailFlow/${cat}`).join(' OR ')
const query = `(${labelQueries}) before:${before}`
const response = await gmail.gmail.users.messages.list({
userId: 'me',
q: query,
maxResults: Math.min(cap, 500),
})
const ids = (response.data.messages || []).map((m) => m.id).slice(0, cap)
const emails = await gmail.batchGetEmails(ids)
for (const email of emails) {
out.push({
id: email.id,
subject: email.headers?.subject || '',
from: email.headers?.from || '',
date: email.headers?.date || email.internalDate || '',
})
}
} else if (account.provider === 'outlook' && cap > 0) {
const outlook = await getOutlookService(account.accessToken)
const filter = `receivedDateTime lt ${cutoffDate.toISOString()}`
const data = await outlook._request(`/me/messages?$filter=${encodeURIComponent(filter)}&$top=${Math.min(cap, 500)}`)
for (const message of data.value || []) {
const cats = message.categories || []
const match = matchCategories.length === 0 || cats.some((c) => matchCategories.includes(c))
if (!match) continue
out.push({
id: message.id,
subject: message.subject || '',
from: message.from?.emailAddress?.address || '',
date: message.receivedDateTime || '',
})
if (out.length >= cap) break
}
}
} catch (err) {
log.warn('listPromotionCleanupPreviewMessages failed', { error: err.message })
}
return out.slice(0, cap)
}
export default router export default router

View File

@@ -6,14 +6,26 @@
import express from 'express' import express from 'express'
import { OAuth2Client } from 'google-auth-library' import { OAuth2Client } from 'google-auth-library'
import { ConfidentialClientApplication } from '@azure/msal-node' import { ConfidentialClientApplication } from '@azure/msal-node'
import { asyncHandler, ValidationError, AppError } from '../middleware/errorHandler.mjs' import { asyncHandler, ValidationError, AppError, AuthorizationError } from '../middleware/errorHandler.mjs'
import { respond } from '../utils/response.mjs' import { respond } from '../utils/response.mjs'
import { emailAccounts } from '../services/database.mjs' import { emailAccounts } from '../services/database.mjs'
import { config, features } from '../config/index.mjs' import { config, features } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs' import { log } from '../middleware/logger.mjs'
import { requireAuth } from '../middleware/auth.mjs'
import { buildOAuthState, parseOAuthState } from '../utils/oauth-state.mjs'
const router = express.Router() const router = express.Router()
function requireAuthUnlessOAuthPublic(req, res, next) {
const p = req.path || ''
if (['/gmail/callback', '/outlook/callback', '/status'].includes(p)) {
return next()
}
return requireAuth(req, res, next)
}
router.use(requireAuthUnlessOAuthPublic)
// Google OAuth client (lazy initialization) // Google OAuth client (lazy initialization)
let googleClient = null let googleClient = null
@@ -71,12 +83,6 @@ const OUTLOOK_SCOPES = [
* Initiate Gmail OAuth flow * Initiate Gmail OAuth flow
*/ */
router.get('/gmail/connect', asyncHandler(async (req, res) => { router.get('/gmail/connect', asyncHandler(async (req, res) => {
const { userId } = req.query
if (!userId) {
throw new ValidationError('userId ist erforderlich')
}
if (!features.gmail()) { if (!features.gmail()) {
throw new AppError('Gmail OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED') throw new AppError('Gmail OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED')
} }
@@ -86,7 +92,7 @@ router.get('/gmail/connect', asyncHandler(async (req, res) => {
access_type: 'offline', access_type: 'offline',
scope: GMAIL_SCOPES, scope: GMAIL_SCOPES,
prompt: 'consent', prompt: 'consent',
state: JSON.stringify({ userId }), state: buildOAuthState(req.appwriteUser.id),
include_granted_scopes: true, include_granted_scopes: true,
}) })
@@ -118,10 +124,10 @@ router.get('/gmail/callback', asyncHandler(async (req, res) => {
let userId let userId
try { try {
const stateData = JSON.parse(state) const stateData = parseOAuthState(state)
userId = stateData.userId userId = stateData.userId
} catch (e) { } catch (e) {
log.error('Gmail OAuth: State konnte nicht geparst werden', { state }) log.error('Gmail OAuth: State konnte nicht geparst werden', { state, error: e.message })
return res.redirect(`${config.frontendUrl}/settings?error=invalid_state`) return res.redirect(`${config.frontendUrl}/settings?error=invalid_state`)
} }
@@ -214,6 +220,10 @@ router.post('/gmail/refresh', asyncHandler(async (req, res) => {
const account = await emailAccounts.get(accountId) const account = await emailAccounts.get(accountId)
if (account.userId !== req.appwriteUser.id) {
throw new AuthorizationError('No permission for this account')
}
if (account.provider !== 'gmail') { if (account.provider !== 'gmail') {
throw new ValidationError('Kein Gmail-Konto') throw new ValidationError('Kein Gmail-Konto')
} }
@@ -249,12 +259,6 @@ router.post('/gmail/refresh', asyncHandler(async (req, res) => {
* Initiate Outlook OAuth flow * Initiate Outlook OAuth flow
*/ */
router.get('/outlook/connect', asyncHandler(async (req, res) => { router.get('/outlook/connect', asyncHandler(async (req, res) => {
const { userId } = req.query
if (!userId) {
throw new ValidationError('userId ist erforderlich')
}
if (!features.outlook()) { if (!features.outlook()) {
throw new AppError('Outlook OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED') throw new AppError('Outlook OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED')
} }
@@ -263,7 +267,7 @@ router.get('/outlook/connect', asyncHandler(async (req, res) => {
const authUrl = await client.getAuthCodeUrl({ const authUrl = await client.getAuthCodeUrl({
scopes: OUTLOOK_SCOPES, scopes: OUTLOOK_SCOPES,
redirectUri: config.microsoft.redirectUri, redirectUri: config.microsoft.redirectUri,
state: JSON.stringify({ userId }), state: buildOAuthState(req.appwriteUser.id),
prompt: 'select_account', prompt: 'select_account',
}) })
@@ -286,7 +290,14 @@ router.get('/outlook/callback', asyncHandler(async (req, res) => {
throw new ValidationError('Code und State sind erforderlich') throw new ValidationError('Code und State sind erforderlich')
} }
const { userId } = JSON.parse(state) let userId
try {
userId = parseOAuthState(state).userId
} catch (e) {
log.error('Outlook OAuth: invalid state', { error: e.message })
return respond.redirect(res, `${config.frontendUrl}/settings?error=invalid_state`)
}
const client = getMsalClient() const client = getMsalClient()
// Exchange code for tokens // Exchange code for tokens
@@ -334,6 +345,10 @@ router.post('/outlook/refresh', asyncHandler(async (req, res) => {
const account = await emailAccounts.get(accountId) const account = await emailAccounts.get(accountId)
if (account.userId !== req.appwriteUser.id) {
throw new AuthorizationError('No permission for this account')
}
if (account.provider !== 'outlook') { if (account.provider !== 'outlook') {
throw new ValidationError('Kein Outlook-Konto') throw new ValidationError('Kein Outlook-Konto')
} }

View File

@@ -5,6 +5,7 @@
import express from 'express' import express from 'express'
import Stripe from 'stripe' import Stripe from 'stripe'
import { Client, Users } from 'node-appwrite'
import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs' import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs'
import { validate, rules } from '../middleware/validate.mjs' import { validate, rules } from '../middleware/validate.mjs'
import { limiters } from '../middleware/rateLimit.mjs' import { limiters } from '../middleware/rateLimit.mjs'
@@ -12,13 +13,55 @@ import { respond } from '../utils/response.mjs'
import { subscriptions, submissions } from '../services/database.mjs' import { subscriptions, submissions } from '../services/database.mjs'
import { config } from '../config/index.mjs' import { config } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs' import { log } from '../middleware/logger.mjs'
import { requireAuth } from '../middleware/auth.mjs'
import { loadEmailTemplate, renderTemplate, sendPlainEmail } from '../utils/mailer.mjs'
const router = express.Router() const router = express.Router()
async function resolveUserEmail(userId, stripeCustomerId) {
if (userId) {
try {
const c = new Client()
.setEndpoint(config.appwrite.endpoint)
.setProject(config.appwrite.projectId)
.setKey(config.appwrite.apiKey)
const u = await new Users(c).get(userId)
if (u.email) return u.email
} catch (e) {
log.warn('Appwrite Users.get failed', { userId, error: e.message })
}
}
if (stripeCustomerId) {
try {
const cust = await stripe.customers.retrieve(String(stripeCustomerId))
if (cust && !cust.deleted && cust.email) return cust.email
} catch (e) {
log.warn('Stripe customer retrieve failed', { error: e.message })
}
}
return null
}
const stripe = new Stripe(config.stripe.secretKey) const stripe = new Stripe(config.stripe.secretKey)
function requireAuthUnlessStripeWebhook(req, res, next) {
if (req.path === '/webhook' && req.method === 'POST') {
return next()
}
return requireAuth(req, res, next)
}
router.use(requireAuthUnlessStripeWebhook)
/** /**
* Plan configuration * Plan configuration
*/ */
const PLAN_DISPLAY_NAMES = {
basic: 'Basic',
pro: 'Pro',
business: 'Business',
free: 'Free',
}
const PLANS = { const PLANS = {
basic: { basic: {
name: 'Basic', name: 'Basic',
@@ -63,12 +106,12 @@ router.post('/checkout',
limiters.auth, limiters.auth,
validate({ validate({
body: { body: {
userId: [rules.required('userId')],
plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])], plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])],
}, },
}), }),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId, plan, email } = req.body const userId = req.appwriteUser.id
const { plan, email } = req.body
const planConfig = PLANS[plan] const planConfig = PLANS[plan]
if (!planConfig) { if (!planConfig) {
@@ -76,7 +119,7 @@ router.post('/checkout',
} }
// Check for existing subscription // Check for existing subscription
const existing = await subscriptions.getByUser(userId) const existing = await subscriptions.getByUser(userId, req.appwriteUser?.email)
let customerId = existing?.stripeCustomerId let customerId = existing?.stripeCustomerId
// Create checkout session // Create checkout session
@@ -124,31 +167,26 @@ router.post('/checkout',
* Get user's subscription status * Get user's subscription status
*/ */
router.get('/status', asyncHandler(async (req, res) => { router.get('/status', asyncHandler(async (req, res) => {
const { userId } = req.query const userId = req.appwriteUser.id
if (!userId) { const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
throw new ValidationError('userId ist erforderlich') const topKey = config.topSubscriptionPlan
} const plan = sub.plan || topKey
const features =
const sub = await subscriptions.getByUser(userId) PLANS[plan]?.features ||
PLANS[topKey]?.features ||
if (!sub) { PLANS.business.features
// No subscription - return trial info
return respond.success(res, {
status: 'trial',
plan: 'pro',
features: PLANS.pro.features,
trialEndsAt: null, // Would calculate from user creation date
cancelAtPeriodEnd: false,
})
}
respond.success(res, { respond.success(res, {
status: sub.status, status: sub.status || 'active',
plan: sub.plan, plan,
features: PLANS[sub.plan]?.features || PLANS.basic.features, planDisplayName: PLAN_DISPLAY_NAMES[plan] || PLAN_DISPLAY_NAMES[topKey] || 'Business',
isFreeTier: Boolean(sub.isFreeTier),
emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0,
emailsLimit: sub.emailsLimit ?? -1,
features,
currentPeriodEnd: sub.currentPeriodEnd, currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false, cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
}) })
})) }))
@@ -157,15 +195,10 @@ router.get('/status', asyncHandler(async (req, res) => {
* Create Stripe Customer Portal session * Create Stripe Customer Portal session
*/ */
router.post('/portal', router.post('/portal',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.body const userId = req.appwriteUser.id
const sub = await subscriptions.getByUser(userId) const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
if (!sub?.stripeCustomerId) { if (!sub?.stripeCustomerId) {
throw new NotFoundError('Subscription') throw new NotFoundError('Subscription')
@@ -185,15 +218,10 @@ router.post('/portal',
* Cancel subscription at period end * Cancel subscription at period end
*/ */
router.post('/cancel', router.post('/cancel',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.body const userId = req.appwriteUser.id
const sub = await subscriptions.getByUser(userId) const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
if (!sub?.stripeSubscriptionId) { if (!sub?.stripeSubscriptionId) {
throw new NotFoundError('Subscription') throw new NotFoundError('Subscription')
@@ -216,15 +244,10 @@ router.post('/cancel',
* Reactivate cancelled subscription * Reactivate cancelled subscription
*/ */
router.post('/reactivate', router.post('/reactivate',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {
const { userId } = req.body const userId = req.appwriteUser.id
const sub = await subscriptions.getByUser(userId) const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
if (!sub?.stripeSubscriptionId) { if (!sub?.stripeSubscriptionId) {
throw new NotFoundError('Subscription') throw new NotFoundError('Subscription')
@@ -304,6 +327,29 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
}) })
log.info(`Subscription aktualisiert: ${subscription.id}`) log.info(`Subscription aktualisiert: ${subscription.id}`)
try {
const to = await resolveUserEmail(sub.userId, subscription.customer)
if (to) {
const plan = subscription.metadata?.plan || sub.plan || 'current'
const periodEnd = subscription.current_period_end
? new Date(subscription.current_period_end * 1000).toISOString()
: ''
const tpl = loadEmailTemplate('subscription-updated')
const text = renderTemplate(tpl, {
plan: String(plan),
status: String(subscription.status || ''),
periodEndLine: periodEnd ? `Current period ends: ${periodEnd}` : '',
})
await sendPlainEmail({
to,
subject: 'MailFlow — Subscription updated',
text,
})
}
} catch (e) {
log.warn('subscription.updated email skipped', { error: e.message })
}
} }
break break
} }
@@ -318,6 +364,23 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
}) })
log.info(`Subscription gelöscht: ${subscription.id}`) log.info(`Subscription gelöscht: ${subscription.id}`)
try {
const to = await resolveUserEmail(sub.userId, subscription.customer)
if (to) {
const tpl = loadEmailTemplate('subscription-ended')
const text = renderTemplate(tpl, {
endedDate: new Date().toISOString(),
})
await sendPlainEmail({
to,
subject: 'MailFlow — Your subscription has ended',
text,
})
}
} catch (e) {
log.warn('subscription.deleted email skipped', { error: e.message })
}
} }
break break
} }
@@ -327,7 +390,27 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, { log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, {
customer: invoice.customer, customer: invoice.customer,
}) })
// TODO: Send notification email try {
let metaUserId
if (invoice.subscription) {
const subStripe = await stripe.subscriptions.retrieve(invoice.subscription)
metaUserId = subStripe.metadata?.userId
}
const to = await resolveUserEmail(metaUserId, invoice.customer)
if (to) {
const tpl = loadEmailTemplate('payment-failed')
const text = renderTemplate(tpl, {
invoiceId: String(invoice.id || ''),
})
await sendPlainEmail({
to,
subject: 'MailFlow — Payment failed, please update billing',
text,
})
}
} catch (e) {
log.warn('invoice.payment_failed email skipped', { error: e.message })
}
break break
} }

View File

@@ -4,7 +4,7 @@
*/ */
import { Client, Databases, Query, ID } from 'node-appwrite' import { Client, Databases, Query, ID } from 'node-appwrite'
import { config } from '../config/index.mjs' import { config, isAdmin } from '../config/index.mjs'
import { NotFoundError } from '../middleware/errorHandler.mjs' import { NotFoundError } from '../middleware/errorHandler.mjs'
// Initialize Appwrite client // Initialize Appwrite client
@@ -236,22 +236,26 @@ export const emailStats = {
}, },
async resetDaily() { async resetDaily() {
// Reset daily counters - would be called by a cron job
const allStats = await db.list(Collections.EMAIL_STATS, []) const allStats = await db.list(Collections.EMAIL_STATS, [])
let n = 0
for (const stat of allStats) { for (const stat of allStats) {
await db.update(Collections.EMAIL_STATS, stat.$id, { todaySorted: 0 }) await db.update(Collections.EMAIL_STATS, stat.$id, { todaySorted: 0 })
n++
} }
return n
}, },
async resetWeekly() { async resetWeekly() {
// Reset weekly counters - would be called by a cron job
const allStats = await db.list(Collections.EMAIL_STATS, []) const allStats = await db.list(Collections.EMAIL_STATS, [])
let n = 0
for (const stat of allStats) { for (const stat of allStats) {
await db.update(Collections.EMAIL_STATS, stat.$id, { await db.update(Collections.EMAIL_STATS, stat.$id, {
weekSorted: 0, weekSorted: 0,
categoriesJson: '{}', categoriesJson: '{}',
}) })
n++
} }
return n
}, },
} }
@@ -299,42 +303,60 @@ export const emailUsage = {
* Subscriptions operations * Subscriptions operations
*/ */
export const subscriptions = { export const subscriptions = {
async getByUser(userId) { /**
* @param {string} userId
* @param {string|null} [viewerEmail] - if set and isAdmin(email), effective plan is business (highest tier)
*/
async getByUser(userId, viewerEmail = null) {
const subscription = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)]) const subscription = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
let result
// If no subscription, user is on free tier // If no subscription, user is on free tier
if (!subscription) { if (!subscription) {
const usage = await emailUsage.getUsage(userId) const usage = await emailUsage.getUsage(userId)
return { result = {
plan: 'free', plan: 'free',
status: 'active', status: 'active',
isFreeTier: true, isFreeTier: true,
emailsUsedThisMonth: usage.emailsProcessed, emailsUsedThisMonth: usage.emailsProcessed,
emailsLimit: 500, // From config emailsLimit: 500, // From config
} }
} else {
// 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
}
result = {
...subscription,
plan: subscription.plan || 'free',
isFreeTier,
emailsUsedThisMonth,
emailsLimit,
}
} }
// Check if subscription is active if (viewerEmail && isAdmin(viewerEmail)) {
const isActive = subscription.status === 'active' return {
const isFreeTier = !isActive || subscription.plan === 'free' ...result,
plan: config.topSubscriptionPlan,
// Get usage for free tier users status: 'active',
let emailsUsedThisMonth = 0 isFreeTier: false,
let emailsLimit = -1 // Unlimited for paid emailsLimit: -1,
}
if (isFreeTier) {
const usage = await emailUsage.getUsage(userId)
emailsUsedThisMonth = usage.emailsProcessed
emailsLimit = 500 // From config
} }
return { return result
...subscription,
plan: subscription.plan || 'free',
isFreeTier,
emailsUsedThisMonth,
emailsLimit,
}
}, },
async getByStripeId(stripeSubscriptionId) { async getByStripeId(stripeSubscriptionId) {
@@ -352,8 +374,8 @@ export const subscriptions = {
}, },
async upsertByUser(userId, data) { async upsertByUser(userId, data) {
const existing = await this.getByUser(userId) const existing = await db.findOne(Collections.SUBSCRIPTIONS, [Query.equal('userId', userId)])
if (existing) { if (existing?.$id) {
return this.update(existing.$id, data) return this.update(existing.$id, data)
} }
return this.create({ userId, ...data }) return this.create({ userId, ...data })
@@ -377,6 +399,12 @@ export const userPreferences = {
autoDetectCompanies: true, autoDetectCompanies: true,
version: 1, version: 1,
categoryAdvanced: {}, categoryAdvanced: {},
profile: {
displayName: '',
timezone: '',
notificationPrefs: {},
},
cleanupMeta: {},
cleanup: { cleanup: {
enabled: false, enabled: false,
readItems: { readItems: {
@@ -413,6 +441,9 @@ export const userPreferences = {
companyLabels: preferences.companyLabels || defaults.companyLabels, companyLabels: preferences.companyLabels || defaults.companyLabels,
nameLabels: preferences.nameLabels || defaults.nameLabels, nameLabels: preferences.nameLabels || defaults.nameLabels,
autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies, autoDetectCompanies: preferences.autoDetectCompanies !== undefined ? preferences.autoDetectCompanies : defaults.autoDetectCompanies,
profile: preferences.profile != null ? { ...defaults.profile, ...preferences.profile } : defaults.profile,
cleanupMeta:
preferences.cleanupMeta !== undefined ? preferences.cleanupMeta : defaults.cleanupMeta,
} }
}, },

74
server/utils/crypto.mjs Normal file
View File

@@ -0,0 +1,74 @@
/**
* AES-256-GCM for IMAP passwords. ENCRYPTION_KEY = 64 hex chars (32 bytes).
* Legacy: if decrypt fails or key missing, value treated as plaintext.
*/
import crypto from 'crypto'
const ALGO = 'aes-256-gcm'
const IV_LEN = 16
const AUTH_TAG_LEN = 16
function getKeyBuffer() {
const hex = process.env.ENCRYPTION_KEY || ''
if (hex.length !== 64) {
throw new Error('ENCRYPTION_KEY must be 64 hex characters (32 bytes). Generate: openssl rand -hex 32')
}
return Buffer.from(hex, 'hex')
}
export function encrypt(text) {
if (text == null || text === '') return ''
const key = getKeyBuffer()
const iv = crypto.randomBytes(IV_LEN)
const cipher = crypto.createCipheriv(ALGO, key, iv, { authTagLength: AUTH_TAG_LEN })
const enc = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
const combined = Buffer.concat([iv, authTag, enc])
return combined.toString('base64url')
}
export function decrypt(encoded) {
if (!encoded) return ''
const buf = Buffer.from(String(encoded), 'base64url')
if (buf.length < IV_LEN + AUTH_TAG_LEN + 1) {
throw new Error('invalid ciphertext')
}
const key = getKeyBuffer()
const iv = buf.subarray(0, IV_LEN)
const authTag = buf.subarray(IV_LEN, IV_LEN + AUTH_TAG_LEN)
const data = buf.subarray(IV_LEN + AUTH_TAG_LEN)
const decipher = crypto.createDecipheriv(ALGO, key, iv, { authTagLength: AUTH_TAG_LEN })
decipher.setAuthTag(authTag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
}
/** Encrypt IMAP password when ENCRYPTION_KEY is set; otherwise store plaintext. */
export function encryptImapSecret(plain) {
if (plain == null || plain === '') return ''
if (!process.env.ENCRYPTION_KEY) return String(plain)
try {
return encrypt(plain)
} catch (e) {
logWarnOnce('encryptImapSecret', e.message)
return String(plain)
}
}
/** Decrypt IMAP secret; on failure return as plaintext (legacy). */
export function decryptImapSecret(stored) {
if (stored == null || stored === '') return ''
if (!process.env.ENCRYPTION_KEY) return String(stored)
try {
return decrypt(stored)
} catch {
return String(stored)
}
}
let warnedEncrypt = false
function logWarnOnce(tag, msg) {
if (warnedEncrypt) return
warnedEncrypt = true
console.warn(`[crypto] ${tag}: ${msg}`)
}

68
server/utils/mailer.mjs Normal file
View File

@@ -0,0 +1,68 @@
/**
* Plain SMTP mailer (nodemailer). Optional: if SMTP not configured, send is a no-op.
*/
import nodemailer from 'nodemailer'
import { readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { log } from '../middleware/logger.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
let transporter = null
function getTransporter() {
const host = process.env.SMTP_HOST
const user = process.env.SMTP_USER
const pass = process.env.SMTP_PASS
if (!host || !user || !pass) {
return null
}
if (!transporter) {
transporter = nodemailer.createTransport({
host,
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true',
auth: { user, pass },
})
}
return transporter
}
export function renderTemplate(text, vars) {
let out = text
for (const [k, v] of Object.entries(vars || {})) {
out = out.split(`{{${k}}}`).join(v != null ? String(v) : '')
}
return out
}
export function loadEmailTemplate(name) {
const path = join(__dirname, '..', 'emails', `${name}.txt`)
return readFileSync(path, 'utf8')
}
/**
* Send plain-text email. Returns false if SMTP not configured or send failed (logged).
*/
export async function sendPlainEmail({ to, subject, text }) {
const from = process.env.SMTP_FROM || process.env.SMTP_USER
if (!to || !subject || !text) {
log.warn('sendPlainEmail: missing to/subject/text')
return false
}
const tx = getTransporter()
if (!tx) {
log.warn('SMTP not configured (SMTP_HOST/SMTP_USER/SMTP_PASS); email skipped')
return false
}
try {
await tx.sendMail({ from, to, subject, text })
log.info(`Email sent to ${to}: ${subject}`)
return true
} catch (e) {
log.error('sendPlainEmail failed', { error: e.message, to })
return false
}
}

View File

@@ -0,0 +1,44 @@
/**
* Signed OAuth state (userId) to prevent tampering when OAUTH_STATE_SECRET is set.
*/
import crypto from 'crypto'
import { config } from '../config/index.mjs'
export function buildOAuthState(userId) {
const secret = config.oauthStateSecret
if (!secret) {
return JSON.stringify({ userId })
}
const body = JSON.stringify({ userId, exp: Date.now() + 15 * 60 * 1000 })
const sig = crypto.createHmac('sha256', secret).update(body).digest('hex')
return Buffer.from(JSON.stringify({ b: body, s: sig })).toString('base64url')
}
export function parseOAuthState(state) {
if (!state || typeof state !== 'string') {
throw new Error('invalid_state')
}
const trimmed = state.trim()
const secret = config.oauthStateSecret
if (trimmed.startsWith('{')) {
const legacy = JSON.parse(trimmed)
if (!legacy.userId) throw new Error('invalid_state')
if (secret) {
throw new Error('unsigned_state_rejected')
}
return { userId: legacy.userId }
}
const raw = Buffer.from(trimmed, 'base64url').toString('utf8')
const outer = JSON.parse(raw)
if (!outer.b || !outer.s) throw new Error('invalid_state')
if (!secret) throw new Error('signed_state_requires_secret')
const expected = crypto.createHmac('sha256', secret).update(outer.b).digest('hex')
if (outer.s !== expected) throw new Error('invalid_state_signature')
const payload = JSON.parse(outer.b)
if (payload.exp != null && payload.exp < Date.now()) throw new Error('state_expired')
if (!payload.userId) throw new Error('invalid_state')
return { userId: payload.userId }
}