- Webhook route and Gitea integration - IMAP service and Nextcloud/Porkbun setup docs - Settings UI improvements and API updates - SSH/Webhook fix prompt for emailsorter.webklar.com - Bootstrap, config and AI sorter updates
590 lines
21 KiB
TypeScript
590 lines
21 KiB
TypeScript
const API_BASE = import.meta.env.VITE_API_URL || '/api'
|
||
|
||
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 response = await fetch(`${API_BASE}${endpoint}`, {
|
||
...options,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...options?.headers,
|
||
},
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
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'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export const api = {
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// EMAIL ACCOUNTS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getEmailAccounts(userId: string) {
|
||
return fetchApi<Array<{
|
||
id: string
|
||
email: string
|
||
provider: 'gmail' | 'outlook' | 'imap'
|
||
connected: boolean
|
||
lastSync?: string
|
||
}>>(`/email/accounts?userId=${userId}`)
|
||
},
|
||
|
||
async connectEmailAccount(userId: string, 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 }),
|
||
})
|
||
},
|
||
|
||
async connectImapAccount(
|
||
userId: string,
|
||
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,
|
||
imapHost: params.imapHost,
|
||
imapPort: params.imapPort,
|
||
imapSecure: params.imapSecure,
|
||
}),
|
||
})
|
||
},
|
||
|
||
async disconnectEmailAccount(accountId: string, userId: string) {
|
||
return fetchApi<{ success: boolean }>(`/email/accounts/${accountId}?userId=${userId}`, {
|
||
method: 'DELETE',
|
||
})
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// EMAIL STATS & SORTING
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getEmailStats(userId: string) {
|
||
return fetchApi<{
|
||
totalSorted: number
|
||
todaySorted: number
|
||
weekSorted: number
|
||
categories: Record<string, number>
|
||
timeSaved: number
|
||
}>(`/email/stats?userId=${userId}`)
|
||
},
|
||
|
||
async sortEmails(userId: string, 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({ userId, accountId, maxEmails, processAll }),
|
||
})
|
||
},
|
||
|
||
// Demo sorting without account (for quick tests)
|
||
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 }),
|
||
})
|
||
},
|
||
|
||
// Connect demo account
|
||
async connectDemoAccount(userId: string) {
|
||
return fetchApi<{
|
||
accountId: string
|
||
email: string
|
||
provider: string
|
||
message?: string
|
||
}>('/email/connect-demo', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
},
|
||
|
||
// Get categories
|
||
async getCategories() {
|
||
return fetchApi<Array<{
|
||
id: string
|
||
name: string
|
||
description: string
|
||
color: string
|
||
action: string
|
||
priority: number
|
||
}>>('/email/categories')
|
||
},
|
||
|
||
// Get today's digest
|
||
async getDigest(userId: string) {
|
||
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?userId=${userId}`)
|
||
},
|
||
|
||
// Get digest history
|
||
async getDigestHistory(userId: string, 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?userId=${userId}&days=${days}`)
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// OAUTH
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getOAuthUrl(provider: 'gmail' | 'outlook', userId: string) {
|
||
return fetchApi<{ url: string }>(`/oauth/${provider}/connect?userId=${userId}`)
|
||
},
|
||
|
||
async getOAuthStatus() {
|
||
return fetchApi<{
|
||
gmail: { enabled: boolean; scopes: string[] }
|
||
outlook: { enabled: boolean; scopes: string[] }
|
||
}>('/oauth/status')
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// SUBSCRIPTION
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getSubscriptionStatus(userId: string) {
|
||
return fetchApi<{
|
||
status: string
|
||
plan: string
|
||
isFreeTier: boolean
|
||
emailsUsedThisMonth?: number
|
||
emailsLimit?: number
|
||
features: {
|
||
emailAccounts: number
|
||
emailsPerDay: number
|
||
historicalSync: boolean
|
||
customRules: boolean
|
||
prioritySupport: boolean
|
||
}
|
||
currentPeriodEnd?: string
|
||
cancelAtPeriodEnd?: boolean
|
||
}>(`/subscription/status?userId=${userId}`)
|
||
},
|
||
|
||
async createSubscriptionCheckout(plan: string, userId: string, email?: string) {
|
||
return fetchApi<{ url: string; sessionId: string }>('/subscription/checkout', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId, plan, email }),
|
||
})
|
||
},
|
||
|
||
async createPortalSession(userId: string) {
|
||
return fetchApi<{ url: string }>('/subscription/portal', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
},
|
||
|
||
async cancelSubscription(userId: string) {
|
||
return fetchApi<{ success: boolean }>('/subscription/cancel', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
},
|
||
|
||
async reactivateSubscription(userId: string) {
|
||
return fetchApi<{ success: boolean }>('/subscription/reactivate', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// USER PREFERENCES
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getUserPreferences(userId: string) {
|
||
return fetchApi<{
|
||
vipSenders: Array<{ email: string; name?: string }>
|
||
blockedSenders: string[]
|
||
customRules: Array<{ condition: string; category: string }>
|
||
priorityTopics: string[]
|
||
}>(`/preferences?userId=${userId}`)
|
||
},
|
||
|
||
async saveUserPreferences(userId: string, 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({ userId, ...preferences }),
|
||
})
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// AI CONTROL
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getAIControlSettings(userId: string) {
|
||
return fetchApi<{
|
||
enabledCategories: string[]
|
||
categoryActions: Record<string, 'inbox' | 'archive_read' | 'star'>
|
||
autoDetectCompanies: boolean
|
||
cleanup?: unknown
|
||
categoryAdvanced?: Record<string, unknown>
|
||
version?: number
|
||
}>(`/preferences/ai-control?userId=${userId}`)
|
||
},
|
||
|
||
async saveAIControlSettings(userId: string, 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({ userId, ...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
|
||
return fetchApi<{
|
||
preview: Array<{
|
||
id: string
|
||
subject: string
|
||
from: string
|
||
date: string
|
||
reason: 'read' | 'promotion'
|
||
}>
|
||
}>(`/preferences/ai-control/cleanup/preview?userId=${userId}`)
|
||
},
|
||
|
||
// 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
|
||
return fetchApi<{
|
||
usersProcessed: number
|
||
emailsProcessed: {
|
||
readItems: number
|
||
promotions: number
|
||
}
|
||
errors: Array<{ userId: string; error: string }>
|
||
}>('/email/cleanup', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
},
|
||
|
||
// 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
|
||
return fetchApi<{
|
||
lastRun?: string
|
||
lastRunCounts?: {
|
||
readItems: number
|
||
promotions: number
|
||
}
|
||
}>(`/preferences/ai-control/cleanup/status?userId=${userId}`)
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// COMPANY LABELS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getCompanyLabels(userId: string) {
|
||
return fetchApi<Array<{
|
||
id?: string
|
||
name: string
|
||
condition: string
|
||
enabled: boolean
|
||
category?: string
|
||
}>>(`/preferences/company-labels?userId=${userId}`)
|
||
},
|
||
|
||
async saveCompanyLabel(userId: string, 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({ userId, companyLabel }),
|
||
})
|
||
},
|
||
|
||
async deleteCompanyLabel(userId: string, labelId: string) {
|
||
return fetchApi<{ success: boolean }>(`/preferences/company-labels/${labelId}?userId=${userId}`, {
|
||
method: 'DELETE',
|
||
})
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// ME / ADMIN
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getMe(email: string) {
|
||
return fetchApi<{ isAdmin: boolean }>(`/me?email=${encodeURIComponent(email)}`)
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// NAME LABELS (Workers – Admin only)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getNameLabels(userId: string, email: string) {
|
||
return fetchApi<Array<{
|
||
id?: string
|
||
name: string
|
||
email?: string
|
||
keywords?: string[]
|
||
enabled: boolean
|
||
}>>(`/preferences/name-labels?userId=${userId}&email=${encodeURIComponent(email)}`)
|
||
},
|
||
|
||
async saveNameLabel(
|
||
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 }>(
|
||
'/preferences/name-labels',
|
||
{
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId, email: userEmail, nameLabel }),
|
||
}
|
||
)
|
||
},
|
||
|
||
async deleteNameLabel(userId: string, userEmail: string, labelId: string) {
|
||
return fetchApi<{ success: boolean }>(
|
||
`/preferences/name-labels/${labelId}?userId=${userId}&email=${encodeURIComponent(userEmail)}`,
|
||
{ 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(userId: string) {
|
||
return fetchApi<{
|
||
onboarding_step: string
|
||
completedSteps: string[]
|
||
first_value_seen_at?: string
|
||
skipped_at?: string
|
||
}>(`/onboarding/status?userId=${userId}`)
|
||
},
|
||
|
||
async updateOnboardingStep(userId: string, step: string, completedSteps: string[] = []) {
|
||
return fetchApi<{ step: string; completedSteps: string[] }>('/onboarding/step', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId, step, completedSteps }),
|
||
})
|
||
},
|
||
|
||
async skipOnboarding(userId: string) {
|
||
return fetchApi<{ skipped: boolean }>('/onboarding/skip', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
},
|
||
|
||
async resumeOnboarding(userId: string) {
|
||
return fetchApi<{
|
||
onboarding_step: string
|
||
completedSteps: string[]
|
||
}>('/onboarding/resume', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// ACCOUNT MANAGEMENT
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async deleteAccount(userId: string) {
|
||
return fetchApi<{ success: boolean }>('/account/delete', {
|
||
method: 'DELETE',
|
||
body: JSON.stringify({ userId }),
|
||
})
|
||
},
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// REFERRALS
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async getReferralCode(userId: string) {
|
||
return fetchApi<{
|
||
referralCode: string
|
||
referralCount: number
|
||
}>(`/referrals/code?userId=${userId}`)
|
||
},
|
||
|
||
async trackReferral(userId: string, referralCode: string) {
|
||
return fetchApi<{ success: boolean }>('/referrals/track', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ userId, referralCode }),
|
||
})
|
||
},
|
||
}
|
||
|
||
export default api
|