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,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> {
success?: boolean
@@ -17,32 +75,58 @@ async function fetchApi<T>(
options?: RequestInit
): Promise<ApiResponse<T>> {
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,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
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) {
return {
error: data.error || {
code: 'UNKNOWN',
message: `HTTP ${response.status}`
}
if (!isJson) {
return {
error: {
code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE',
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) {
return {
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network error'
}
return {
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network error',
},
}
}
}
@@ -51,32 +135,34 @@ export const api = {
// ═══════════════════════════════════════════════════════════════════════════
// EMAIL ACCOUNTS
// ═══════════════════════════════════════════════════════════════════════════
async getEmailAccounts(userId: string) {
async getEmailAccounts() {
return fetchApi<Array<{
id: string
email: string
provider: 'gmail' | 'outlook' | 'imap'
connected: boolean
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', {
method: 'POST',
body: JSON.stringify({ userId, provider, email, accessToken, refreshToken }),
body: JSON.stringify({ provider, email, accessToken, refreshToken }),
})
},
async connectImapAccount(
userId: string,
params: { email: string; password: string; imapHost?: string; imapPort?: number; imapSecure?: boolean }
) {
async connectImapAccount(params: {
email: string
password: string
imapHost?: string
imapPort?: number
imapSecure?: boolean
}) {
return fetchApi<{ accountId: string }>('/email/connect', {
method: 'POST',
body: JSON.stringify({
userId,
provider: 'imap',
email: params.email,
accessToken: params.password,
@@ -87,8 +173,8 @@ export const api = {
})
},
async disconnectEmailAccount(accountId: string, userId: string) {
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, {
async disconnectEmailAccount(accountId: string) {
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}`, {
method: 'DELETE',
})
},
@@ -97,17 +183,17 @@ export const api = {
// EMAIL STATS & SORTING
// ═══════════════════════════════════════════════════════════════════════════
async getEmailStats(userId: string) {
async getEmailStats() {
return fetchApi<{
totalSorted: number
todaySorted: number
weekSorted: number
categories: Record<string, 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<{
sorted: number
inboxCleared: number
@@ -127,11 +213,10 @@ export const api = {
}>
}>('/email/sort', {
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) {
return fetchApi<{
sorted: number
@@ -152,8 +237,7 @@ export const api = {
})
},
// Connect demo account
async connectDemoAccount(userId: string) {
async connectDemoAccount() {
return fetchApi<{
accountId: string
email: string
@@ -161,11 +245,10 @@ export const api = {
message?: string
}>('/email/connect-demo', {
method: 'POST',
body: JSON.stringify({ userId }),
body: JSON.stringify({}),
})
},
// Get categories
async getCategories() {
return fetchApi<Array<{
id: string
@@ -177,8 +260,7 @@ export const api = {
}>>('/email/categories')
},
// Get today's digest
async getDigest(userId: string) {
async getDigest() {
return fetchApi<{
date: string
totalSorted: number
@@ -188,11 +270,10 @@ export const api = {
highlights: Array<{ type: string; count: number; message: string }>
suggestions: Array<{ type: string; message: string }>
hasData: boolean
}>(`/email/digest?userId=${userId}`)
}>('/email/digest')
},
// Get digest history
async getDigestHistory(userId: string, days: number = 7) {
async getDigestHistory(days: number = 7) {
return fetchApi<{
days: number
digests: Array<{
@@ -207,15 +288,15 @@ export const api = {
inboxCleared: number
timeSavedMinutes: number
}
}>(`/email/digest/history?userId=${userId}&days=${days}`)
}>(`/email/digest/history?days=${days}`)
},
// ═══════════════════════════════════════════════════════════════════════════
// OAUTH
// ═══════════════════════════════════════════════════════════════════════════
async getOAuthUrl(provider: 'gmail' | 'outlook', userId: string) {
return fetchApi<{ url: string }>(`/oauth/${provider}/connect?userId=${userId}`)
async getOAuthUrl(provider: 'gmail' | 'outlook') {
return fetchApi<{ url: string }>(`/oauth/${provider}/connect`)
},
async getOAuthStatus() {
@@ -229,10 +310,11 @@ export const api = {
// SUBSCRIPTION
// ═══════════════════════════════════════════════════════════════════════════
async getSubscriptionStatus(userId: string) {
async getSubscriptionStatus() {
return fetchApi<{
status: string
plan: string
planDisplayName?: string
isFreeTier: boolean
emailsUsedThisMonth?: number
emailsLimit?: number
@@ -245,34 +327,34 @@ export const api = {
}
currentPeriodEnd?: string
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', {
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', {
method: 'POST',
body: JSON.stringify({ userId }),
body: JSON.stringify({}),
})
},
async cancelSubscription(userId: string) {
async cancelSubscription() {
return fetchApi<{ success: boolean }>('/subscription/cancel', {
method: 'POST',
body: JSON.stringify({ userId }),
body: JSON.stringify({}),
})
},
async reactivateSubscription(userId: string) {
async reactivateSubscription() {
return fetchApi<{ success: boolean }>('/subscription/reactivate', {
method: 'POST',
body: JSON.stringify({ userId }),
body: JSON.stringify({}),
})
},
@@ -280,16 +362,21 @@ export const api = {
// USER PREFERENCES
// ═══════════════════════════════════════════════════════════════════════════
async getUserPreferences(userId: string) {
async getUserPreferences() {
return fetchApi<{
vipSenders: Array<{ email: string; name?: string }>
blockedSenders: string[]
customRules: Array<{ condition: string; category: 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 }>
blockedSenders?: string[]
customRules?: Array<{ condition: string; category: string }>
@@ -298,7 +385,7 @@ export const api = {
}) {
return fetchApi<{ success: boolean }>('/preferences', {
method: 'POST',
body: JSON.stringify({ userId, ...preferences }),
body: JSON.stringify(preferences),
})
},
@@ -306,7 +393,7 @@ export const api = {
// AI CONTROL
// ═══════════════════════════════════════════════════════════════════════════
async getAIControlSettings(userId: string) {
async getAIControlSettings() {
return fetchApi<{
enabledCategories: string[]
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
@@ -314,10 +401,10 @@ export const api = {
cleanup?: unknown
categoryAdvanced?: Record<string, unknown>
version?: number
}>(`/preferences/ai-control?userId=${userId}`)
}>('/preferences/ai-control')
},
async saveAIControlSettings(userId: string, settings: {
async saveAIControlSettings(settings: {
enabledCategories?: string[]
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies?: boolean
@@ -327,33 +414,24 @@ export const api = {
}) {
return fetchApi<{ success: boolean }>('/preferences/ai-control', {
method: 'POST',
body: JSON.stringify({ userId, ...settings }),
body: JSON.stringify(settings),
})
},
// Cleanup Preview - shows what would be cleaned up without actually doing it
// 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
async getCleanupPreview(accountId: string) {
return fetchApi<{
preview: Array<{
messages: Array<{
id: string
subject: string
from: string
date: string
reason: 'read' | 'promotion'
}>
}>(`/preferences/ai-control/cleanup/preview?userId=${userId}`)
count: number
}>(`/email/${accountId}/cleanup/preview`)
},
// Run cleanup now - executes cleanup for the user
// 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
async runCleanup() {
return fetchApi<{
usersProcessed: number
emailsProcessed: {
@@ -363,40 +441,47 @@ export const api = {
errors: Array<{ userId: string; error: string }>
}>('/email/cleanup', {
method: 'POST',
body: JSON.stringify({ userId }),
body: JSON.stringify({}),
})
},
// Get cleanup status - last run info and counts
// 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
async getCleanupStatus(accountId: string) {
return fetchApi<{
lastRun?: string
lastRunCounts?: {
readItems: 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
// ═══════════════════════════════════════════════════════════════════════════
async getCompanyLabels(userId: string) {
async getCompanyLabels() {
return fetchApi<Array<{
id?: string
name: string
condition: string
enabled: boolean
category?: string
}>>(`/preferences/company-labels?userId=${userId}`)
}>>('/preferences/company-labels')
},
async saveCompanyLabel(userId: string, companyLabel: {
async saveCompanyLabel(companyLabel: {
id?: string
name: string
condition: string
@@ -411,12 +496,12 @@ export const api = {
category?: string
}>('/preferences/company-labels', {
method: 'POST',
body: JSON.stringify({ userId, companyLabel }),
body: JSON.stringify({ companyLabel }),
})
},
async deleteCompanyLabel(userId: string, labelId: string) {
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}?userId=${userId}`, {
async deleteCompanyLabel(labelId: string) {
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}`, {
method: 'DELETE',
})
},
@@ -425,43 +510,36 @@ export const api = {
// ME / ADMIN
// ═══════════════════════════════════════════════════════════════════════════
async getMe(email: string) {
return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`)
async getMe() {
return fetchApi<{ isAdmin: boolean }>('/me')
},
// ═══════════════════════════════════════════════════════════════════════════
// NAME LABELS (Workers Admin only)
// ═══════════════════════════════════════════════════════════════════════════
async getNameLabels(userId: string, email: string) {
async getNameLabels() {
return fetchApi<Array<{
id?: string
name: string
email?: string
keywords?: string[]
enabled: boolean
}>>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`)
}>>('/preferences/name-labels')
},
async saveNameLabel(
userId: string,
userEmail: string,
nameLabel: { id?: string; name: string; email?: string; keywords?: string[]; enabled: boolean }
) {
async saveNameLabel(nameLabel: { 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',
{
method: 'POST',
body: JSON.stringify({ userId, email: userEmail, nameLabel }),
body: JSON.stringify({ nameLabel }),
}
)
},
async deleteNameLabel(userId: string, userEmail: string, labelId: string) {
return fetchApi<{ success: boolean }>(
`/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`,
{ method: 'DELETE' }
)
async deleteNameLabel(labelId: string) {
return fetchApi<{ success: boolean }>(`/preferences/name-labels/${labelId}`, { method: 'DELETE' })
},
// ═══════════════════════════════════════════════════════════════════════════
@@ -523,36 +601,36 @@ export const api = {
// ONBOARDING
// ═══════════════════════════════════════════════════════════════════════════
async getOnboardingStatus(userId: string) {
async getOnboardingStatus() {
return fetchApi<{
onboarding_step: string
completedSteps: string[]
first_value_seen_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', {
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', {
method: 'POST',
body: JSON.stringify({ userId }),
body: JSON.stringify({}),
})
},
async resumeOnboarding(userId: string) {
async resumeOnboarding() {
return fetchApi<{
onboarding_step: string
completedSteps: string[]
}>('/onboarding/resume', {
method: 'POST',
body: JSON.stringify({ userId }),
body: JSON.stringify({}),
})
},
@@ -560,10 +638,10 @@ export const api = {
// ACCOUNT MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════════
async deleteAccount(userId: string) {
async deleteAccount() {
return fetchApi<{ success: boolean }>('/account/delete', {
method: 'DELETE',
body: JSON.stringify({ userId }),
body: JSON.stringify({}),
})
},
@@ -571,17 +649,17 @@ export const api = {
// REFERRALS
// ═══════════════════════════════════════════════════════════════════════════
async getReferralCode(userId: string) {
async getReferralCode() {
return fetchApi<{
referralCode: string
referralCount: number
}>(`/referrals/code?userId=${userId}`)
}>('/referrals/code')
},
async trackReferral(userId: string, referralCode: string) {
async trackReferral(referralCode: string) {
return fetchApi<{ success: boolean }>('/referrals/track', {
method: 'POST',
body: JSON.stringify({ userId, referralCode }),
body: JSON.stringify({ referralCode }),
})
},
}