Files
Emailsorter/client/src/lib/api.ts
ANDJ 89bc86b615 Try
dfssdfsfdsf
2026-04-09 21:00:04 +02:00

784 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T> {
success?: boolean
data?: T
error?: {
code: string
message: string
fields?: Record<string, string[]>
limit?: number
used?: number
}
}
async function fetchApi<T>(
endpoint: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
try {
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 = 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<Array<{
id: string
email: string
provider: 'gmail' | 'outlook' | 'imap'
connected: boolean
lastSync?: string
}>>('/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<string, number>
timeSaved: number
}>('/email/stats')
},
async sortEmails(accountId: string, maxEmails?: number, processAll?: boolean) {
return fetchApi<{
sorted: number
inboxCleared: number
categories: Record<string, number>
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<string, number>
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<Array<{
id: string
name: string
description: string
color: string
action: string
priority: number
}>>('/email/categories')
},
async getDigest() {
return fetchApi<{
date: string
totalSorted: number
inboxCleared: number
timeSavedMinutes: number
stats: Record<string, number>
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<string, number>
}>
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<string, unknown>
}
}>('/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<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies: boolean
cleanup?: unknown
categoryAdvanced?: Record<string, unknown>
version?: number
}>('/preferences/ai-control')
},
async saveAIControlSettings(settings: {
enabledCategories?: string[]
categoryActions?: Record<string, 'inbox' | 'archive_read' | 'star'>
autoDetectCompanies?: boolean
cleanup?: unknown
categoryAdvanced?: Record<string, unknown>
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<string, unknown>
}) {
return fetchApi<{ success: boolean }>('/preferences/profile', {
method: 'PATCH',
body: JSON.stringify(payload),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// COMPANY LABELS
// ═══════════════════════════════════════════════════════════════════════════
async getCompanyLabels() {
return fetchApi<Array<{
id?: string
name: string
condition: string
enabled: boolean
category?: string
}>>('/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<Array<{
id?: string
name: string
email?: string
keywords?: string[]
enabled: boolean
}>>('/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<unknown[]>('/products')
},
async getQuestions(productSlug: string) {
return fetchApi<unknown[]>(`/questions?productSlug=${productSlug}`)
},
async createSubmission(productSlug: string, answers: Record<string, unknown>) {
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