import { getApiJwt } from './appwrite' /** * Replace every `/api/api` in the path with `/api` until stable (avoids Express catch-all 404). * Call on every outgoing API URL before `fetch`. */ export function collapseDoubleApi(url: string): string { if (!url) return url if (/^https?:\/\//i.test(url)) { try { const u = new URL(url) let p = u.pathname let prev = '' while (p !== prev && p.includes('/api/api')) { prev = p p = p.replace(/\/api\/api/g, '/api') } // z. B. //api → /api (sonst 404 auf dem Server) p = p.replace(/\/+/g, '/') if (p !== '/' && !p.startsWith('/')) { p = `/${p}` } u.pathname = p return u.toString() } catch { return url } } let s = url let prev = '' while (s !== prev && s.includes('/api/api')) { prev = s s = s.replace(/\/api\/api/g, '/api') } s = s.replace(/\/+/g, '/') return s } /** * `import.meta.env.VITE_DEV_BACKEND_ORIGIN` (from Vite define) must be origin ONLY, e.g. `http://127.0.0.1:3030`. * Do NOT set a trailing `/api` in .env / .env.local (`VITE_DEV_API_ORIGIN`) — the client appends `/api` once. */ function stripTrailingApiFromOrigin(origin: string): string { let o = origin.replace(/\/+$/, '') while (o.endsWith('/api')) { o = o.slice(0, -4).replace(/\/+$/, '') } return o } /** * Endpoints in this file are paths like `/subscription/status`. * Express mounts the API under `/api`, so the base must end with `/api`. */ function resolveApiBase(): string { const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim() /** Empty / unset → use Vite define (VITE_DEV_BACKEND_ORIGIN) or relative /api */ const hasExplicitViteApiUrl = Boolean(raw) if (import.meta.env.DEV && !hasExplicitViteApiUrl) { const origin = ( import.meta.env as { VITE_DEV_BACKEND_ORIGIN?: string } ).VITE_DEV_BACKEND_ORIGIN?.trim() if (origin) { const o = stripTrailingApiFromOrigin(origin).replace(/\/+$/, '') const withApi = `${o}/api` return collapseDoubleApi(withApi) } } if (!hasExplicitViteApiUrl) return collapseDoubleApi('/api') if (!raw) return collapseDoubleApi('/api') if (raw.startsWith('/')) { const p = raw.replace(/\/+$/, '') || '/api' return collapseDoubleApi(p) } if (!/^https?:\/\//i.test(raw)) { const p = raw.replace(/^\/+/, '').replace(/\/+$/, '') || 'api' const rel = p.startsWith('api') ? `/${p}` : `/api` return collapseDoubleApi(rel) } const normalized = collapseDoubleApi(raw.replace(/\/+$/, '').trim()) try { const u = new URL(normalized) const path = u.pathname.replace(/\/+/g, '/').replace(/\/$/, '') || '/' 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 === '') { const originOnly = normalized.replace(/\/+$/, '') return collapseDoubleApi(`${originOnly}/api`) } if (path.endsWith('/api')) { return collapseDoubleApi(normalized) } const originOnly = normalized.replace(/\/+$/, '') return collapseDoubleApi(`${originOnly}/api`) } catch { return '/api' } } export const API_BASE = collapseDoubleApi(resolveApiBase()) /** Join API base (ends with `/api`) and endpoint (`/email/...`). If endpoint starts with `/api`, strip it once so we never produce `/api/api`. */ function joinApiUrl(base: string, endpoint: string): string { let ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}` if (ep === '/api' || ep.startsWith('/api/')) { ep = ep === '/api' ? '/' : ep.slice(4) if (ep !== '' && !ep.startsWith('/')) { ep = `/${ep}` } } if (/^https?:\/\//i.test(base)) { const joined = `${base.replace(/\/+$/, '')}${ep}` return collapseDoubleApi(joined) } let b = base.trim() if (!b.startsWith('/')) { b = `/${b.replace(/^\/+/, '')}` } b = b.replace(/\/+$/, '') || '/api' return collapseDoubleApi(`${b}${ep}`) } interface ApiResponse { success?: boolean data?: T error?: { code: string message: string fields?: Record limit?: number used?: number } } async function fetchApi( endpoint: string, options?: RequestInit ): Promise> { try { const headers: Record = { ...(options?.headers as Record), } if (!headers['Content-Type']) { headers['Content-Type'] = 'application/json' } const jwt = await getApiJwt() if (jwt) { headers['Authorization'] = `Bearer ${jwt}` } const urlJoined = collapseDoubleApi(joinApiUrl(API_BASE, endpoint)) const response = await fetch(urlJoined, { ...options, headers, }) 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 (!isJson) { const devHint = import.meta.env.DEV && response.status === 404 ? ` API_BASE=${API_BASE}` : '' return { error: { code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE', message: response.status === 404 ? `API 404: Backend antwortet nicht (falscher Port oder alter Prozess). Server starten: cd server && npm run start. PORT wie in server/.env.${devHint}` : `Expected JSON, got ${ct || 'unknown'} (HTTP ${response.status})`, }, } } 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) { const base = error instanceof Error ? error.message : 'Network error' const devHint = import.meta.env.DEV ? ` API_BASE=${API_BASE} — Ist der Server an diesem Port gestartet? (cd server && npm run start)` : '' return { error: { code: 'NETWORK_ERROR', message: `${base}${devHint}`, }, } } } export const api = { // ═══════════════════════════════════════════════════════════════════════════ // EMAIL ACCOUNTS // ═══════════════════════════════════════════════════════════════════════════ async getEmailAccounts() { return fetchApi>('/email/accounts') }, async connectEmailAccount(provider: 'gmail' | 'outlook', email: string, accessToken: string, refreshToken?: string) { return fetchApi<{ accountId: string }>('/email/connect', { method: 'POST', body: JSON.stringify({ provider, email, accessToken, refreshToken }), }) }, async connectImapAccount(params: { email: string password: string imapHost?: string imapPort?: number imapSecure?: boolean }) { return fetchApi<{ accountId: string }>('/email/connect', { method: 'POST', body: JSON.stringify({ provider: 'imap', email: params.email, accessToken: params.password, imapHost: params.imapHost, imapPort: params.imapPort, imapSecure: params.imapSecure, }), }) }, async disconnectEmailAccount(accountId: string) { return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}`, { method: 'DELETE', }) }, // ═══════════════════════════════════════════════════════════════════════════ // EMAIL STATS & SORTING // ═══════════════════════════════════════════════════════════════════════════ async getEmailStats() { return fetchApi<{ totalSorted: number todaySorted: number weekSorted: number categories: Record timeSaved: number }>('/email/stats') }, async sortEmails(accountId: string, maxEmails?: number, processAll?: boolean) { return fetchApi<{ sorted: number inboxCleared: number categories: Record timeSaved: { minutes: number; formatted: string } highlights: Array<{ type: string; count: number; message: string }> suggestions: Array<{ type: string; message: string }> provider?: string isDemo?: boolean isFirstRun?: boolean suggestedRules?: Array<{ type: string name: string description: string confidence: number action?: { name?: string } }> }>('/email/sort', { method: 'POST', body: JSON.stringify({ accountId, maxEmails, processAll }), }) }, async recoverEmails(accountId: string) { return fetchApi<{ recovered: number folders: Array<{ folder: string; count: number }> message: string }>(`/email/recover/${accountId}`, { method: 'POST', body: JSON.stringify({}), }) }, /** Move mail from sort-related folders (Junk, Archive, MailFlow/…) back to INBOX and strip $MailFlow-* keywords (IMAP only). */ async reSortEmails(accountId: string) { return fetchApi<{ recovered: number folders: Array<{ folder: string; count: number }> mailFlowKeywordsStripped: number message: string }>(`/email/re-sort/${accountId}`, { method: 'POST', body: JSON.stringify({}), }) }, async sortDemo(count: number = 10) { return fetchApi<{ sorted: number emails: Array<{ from: string subject: string snippet: string category: string categoryName: string confidence?: number reason?: string }> categories: Record aiEnabled: boolean }>('/email/sort-demo', { method: 'POST', body: JSON.stringify({ count }), }) }, async connectDemoAccount() { return fetchApi<{ accountId: string email: string provider: string message?: string }>('/email/connect-demo', { method: 'POST', body: JSON.stringify({}), }) }, async getCategories() { return fetchApi>('/email/categories') }, async getDigest() { return fetchApi<{ date: string totalSorted: number inboxCleared: number timeSavedMinutes: number stats: Record highlights: Array<{ type: string; count: number; message: string }> suggestions: Array<{ type: string; message: string }> hasData: boolean }>('/email/digest') }, async getDigestHistory(days: number = 7) { return fetchApi<{ days: number digests: Array<{ date: string totalSorted: number inboxCleared: number timeSavedMinutes: number stats: Record }> totals: { totalSorted: number inboxCleared: number timeSavedMinutes: number } }>(`/email/digest/history?days=${days}`) }, // ═══════════════════════════════════════════════════════════════════════════ // OAUTH // ═══════════════════════════════════════════════════════════════════════════ async getOAuthUrl(provider: 'gmail' | 'outlook') { return fetchApi<{ url: string }>(`/oauth/${provider}/connect`) }, async getOAuthStatus() { return fetchApi<{ gmail: { enabled: boolean; scopes: string[] } outlook: { enabled: boolean; scopes: string[] } }>('/oauth/status') }, // ═══════════════════════════════════════════════════════════════════════════ // SUBSCRIPTION // ═══════════════════════════════════════════════════════════════════════════ async getSubscriptionStatus() { return fetchApi<{ status: string plan: string planDisplayName?: string isFreeTier: boolean emailsUsedThisMonth?: number emailsLimit?: number features: { emailAccounts: number emailsPerDay: number historicalSync: boolean customRules: boolean prioritySupport: boolean } currentPeriodEnd?: string cancelAtPeriodEnd?: boolean }>('/subscription/status') }, async createSubscriptionCheckout(plan: string, email?: string) { return fetchApi<{ url: string; sessionId: string }>('/subscription/checkout', { method: 'POST', body: JSON.stringify({ plan, email }), }) }, async createPortalSession() { return fetchApi<{ url: string }>('/subscription/portal', { method: 'POST', body: JSON.stringify({}), }) }, async cancelSubscription() { return fetchApi<{ success: boolean }>('/subscription/cancel', { method: 'POST', body: JSON.stringify({}), }) }, async reactivateSubscription() { return fetchApi<{ success: boolean }>('/subscription/reactivate', { method: 'POST', body: JSON.stringify({}), }) }, // ═══════════════════════════════════════════════════════════════════════════ // USER PREFERENCES // ═══════════════════════════════════════════════════════════════════════════ async getUserPreferences() { return fetchApi<{ vipSenders: Array<{ email: string; name?: string }> blockedSenders: string[] customRules: Array<{ condition: string; category: string }> priorityTopics: string[] profile?: { displayName?: string timezone?: string notificationPrefs?: Record } }>('/preferences') }, async saveUserPreferences(preferences: { vipSenders?: Array<{ email: string; name?: string }> blockedSenders?: string[] customRules?: Array<{ condition: string; category: string }> priorityTopics?: string[] companyLabels?: Array<{ name: string; condition?: string; category: string; enabled: boolean }> }) { return fetchApi<{ success: boolean }>('/preferences', { method: 'POST', body: JSON.stringify(preferences), }) }, // ═══════════════════════════════════════════════════════════════════════════ // AI CONTROL // ═══════════════════════════════════════════════════════════════════════════ async getAIControlSettings() { return fetchApi<{ enabledCategories: string[] categoryActions: Record autoDetectCompanies: boolean cleanup?: unknown categoryAdvanced?: Record version?: number }>('/preferences/ai-control') }, async saveAIControlSettings(settings: { enabledCategories?: string[] categoryActions?: Record autoDetectCompanies?: boolean cleanup?: unknown categoryAdvanced?: Record version?: number }) { return fetchApi<{ success: boolean }>('/preferences/ai-control', { method: 'POST', body: JSON.stringify(settings), }) }, async getCleanupPreview(accountId: string) { return fetchApi<{ messages: Array<{ id: string subject: string from: string date: string reason: 'read' | 'promotion' }> count: number }>(`/email/${accountId}/cleanup/preview`) }, async runCleanup() { return fetchApi<{ usersProcessed: number emailsProcessed: { readItems: number promotions: number } errors: Array<{ userId: string; error: string }> }>('/email/cleanup', { method: 'POST', body: JSON.stringify({}), }) }, async getCleanupStatus(accountId: string) { return fetchApi<{ lastRun?: string lastRunCounts?: { readItems: number promotions: number } lastErrors?: string[] }>(`/email/${accountId}/cleanup/status`) }, async updateProfile(payload: { displayName?: string timezone?: string notificationPrefs?: Record }) { return fetchApi<{ success: boolean }>('/preferences/profile', { method: 'PATCH', body: JSON.stringify(payload), }) }, // ═══════════════════════════════════════════════════════════════════════════ // COMPANY LABELS // ═══════════════════════════════════════════════════════════════════════════ async getCompanyLabels() { return fetchApi>('/preferences/company-labels') }, async saveCompanyLabel(companyLabel: { id?: string name: string condition: string enabled: boolean category?: string }) { return fetchApi<{ id?: string name: string condition: string enabled: boolean category?: string }>('/preferences/company-labels', { method: 'POST', body: JSON.stringify({ companyLabel }), }) }, async deleteCompanyLabel(labelId: string) { return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}`, { method: 'DELETE', }) }, // ═══════════════════════════════════════════════════════════════════════════ // ME / ADMIN // ═══════════════════════════════════════════════════════════════════════════ async getMe() { return fetchApi<{ isAdmin: boolean }>('/me') }, async resetSortData(email: string) { return fetchApi<{ reset: boolean deleted?: { stats: number; digests: number; usage: number } imapCleared?: number }>('/admin/reset-user-sort-data', { method: 'POST', body: JSON.stringify({ email }), }) }, // ═══════════════════════════════════════════════════════════════════════════ // NAME LABELS (Workers – Admin only) // ═══════════════════════════════════════════════════════════════════════════ async getNameLabels() { return fetchApi>('/preferences/name-labels') }, 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({ nameLabel }), } ) }, async deleteNameLabel(labelId: string) { return fetchApi<{ success: boolean }>(`/preferences/name-labels/${labelId}`, { method: 'DELETE' }) }, // ═══════════════════════════════════════════════════════════════════════════ // PRODUCTS & QUESTIONS (Legacy) // ═══════════════════════════════════════════════════════════════════════════ async getProducts() { return fetchApi('/products') }, async getQuestions(productSlug: string) { return fetchApi(`/questions?productSlug=${productSlug}`) }, async createSubmission(productSlug: string, answers: Record) { return fetchApi<{ submissionId: string }>('/submissions', { method: 'POST', body: JSON.stringify({ productSlug, answers }), }) }, async createCheckout(submissionId: string) { return fetchApi<{ url: string; sessionId: string }>('/checkout', { method: 'POST', body: JSON.stringify({ submissionId }), }) }, // ═══════════════════════════════════════════════════════════════════════════ // CONFIG // ═══════════════════════════════════════════════════════════════════════════ async getConfig() { return fetchApi<{ features: { gmail: boolean outlook: boolean ai: boolean } pricing: { basic: { price: number; currency: string; accounts: number } pro: { price: number; currency: string; accounts: number } business: { price: number; currency: string; accounts: number } } }>('/config') }, async healthCheck() { return fetchApi<{ status: string timestamp: string version: string environment: string uptime: number }>('/health') }, // ═══════════════════════════════════════════════════════════════════════════ // ONBOARDING // ═══════════════════════════════════════════════════════════════════════════ async getOnboardingStatus() { return fetchApi<{ onboarding_step: string completedSteps: string[] first_value_seen_at?: string skipped_at?: string }>('/onboarding/status') }, async updateOnboardingStep(step: string, completedSteps: string[] = []) { return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', { method: 'POST', body: JSON.stringify({ step, completedSteps }), }) }, async skipOnboarding() { return fetchApi<{ skipped: boolean }>('/onboarding/skip', { method: 'POST', body: JSON.stringify({}), }) }, async resumeOnboarding() { return fetchApi<{ onboarding_step: string completedSteps: string[] }>('/onboarding/resume', { method: 'POST', body: JSON.stringify({}), }) }, // ═══════════════════════════════════════════════════════════════════════════ // ACCOUNT MANAGEMENT // ═══════════════════════════════════════════════════════════════════════════ async deleteAccount() { return fetchApi<{ success: boolean }>('/account/delete', { method: 'DELETE', body: JSON.stringify({}), }) }, // ═══════════════════════════════════════════════════════════════════════════ // REFERRALS // ═══════════════════════════════════════════════════════════════════════════ async getReferralCode() { return fetchApi<{ referralCode: string referralCount: number }>('/referrals/code') }, async trackReferral(referralCode: string) { return fetchApi<{ success: boolean }>('/referrals/track', { method: 'POST', body: JSON.stringify({ referralCode }), }) }, } export default api