dfssdfsfdsf
This commit is contained in:
2026-04-09 21:00:04 +02:00
parent 983b67e6fc
commit 89bc86b615
27 changed files with 2921 additions and 408 deletions

View File

@@ -1,4 +1,5 @@
import { getApiJwt } from './appwrite'
import { jwtCache } from './appwrite'
import { API_BASE, collapseDoubleApi } from './api'
/**
* Analytics & Tracking Utility
@@ -164,10 +165,10 @@ export async function trackEvent(
}
try {
const jwt = await getApiJwt()
const jwt = jwtCache?.token ?? null // use cached only, don't fetch
if (!jwt) return
await fetch('/api/analytics/track', {
await fetch(collapseDoubleApi(`${API_BASE.replace(/\/$/, '')}/analytics/track`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -1,29 +1,92 @@
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`.
* 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'
/** 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 p
return collapseDoubleApi(p)
}
if (!/^https?:\/\//i.test(raw)) {
const p = raw.replace(/^\/+/, '').replace(/\/+$/, '') || 'api'
return p.startsWith('api') ? `/${p}` : `/api`
const rel = p.startsWith('api') ? `/${p}` : `/api`
return collapseDoubleApi(rel)
}
const normalized = raw.replace(/\/+$/, '')
const normalized = collapseDoubleApi(raw.replace(/\/+$/, '').trim())
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 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'))
@@ -31,31 +94,40 @@ function resolveApiBase(): string {
return '/api'
}
if (path === '/' || path === '') {
return `${normalized}/api`
const originOnly = normalized.replace(/\/+$/, '')
return collapseDoubleApi(`${originOnly}/api`)
}
if (path.endsWith('/api')) {
return normalized
return collapseDoubleApi(normalized)
}
return `${normalized}/api`
const originOnly = normalized.replace(/\/+$/, '')
return collapseDoubleApi(`${originOnly}/api`)
} catch {
return '/api'
}
}
const API_BASE = resolveApiBase()
export const API_BASE = collapseDoubleApi(resolveApiBase())
/** Root-relative or absolute API URL; avoids `api/foo` (relative to current route). */
/** 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 {
const ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
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)) {
return `${base.replace(/\/+$/, '')}${ep}`
const joined = `${base.replace(/\/+$/, '')}${ep}`
return collapseDoubleApi(joined)
}
let b = base.trim()
if (!b.startsWith('/')) {
b = `/${b.replace(/^\/+/, '')}`
}
b = b.replace(/\/+$/, '') || '/api'
return `${b}${ep}`
return collapseDoubleApi(`${b}${ep}`)
}
interface ApiResponse<T> {
@@ -86,7 +158,7 @@ async function fetchApi<T>(
headers['Authorization'] = `Bearer ${jwt}`
}
const urlJoined = joinApiUrl(API_BASE, endpoint)
const urlJoined = collapseDoubleApi(joinApiUrl(API_BASE, endpoint))
const response = await fetch(urlJoined, {
...options,
@@ -100,12 +172,16 @@ async function fetchApi<T>(
: { 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 unreachable or wrong port — check server is running and VITE_DEV_API_ORIGIN matches PORT.'
? `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})`,
},
}
@@ -122,10 +198,15 @@ async function fetchApi<T>(
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: error instanceof Error ? error.message : 'Network error',
message: `${base}${devHint}`,
},
}
}
@@ -217,6 +298,30 @@ export const api = {
})
},
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
@@ -514,6 +619,17 @@ export const api = {
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)
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -51,35 +51,51 @@ export const account = new Account(client)
export const databases = new Databases(client)
export { ID }
const JWT_BUFFER_MS = 30_000
let jwtCache: { token: string; expMs: number } | null = null
export let jwtCache: { token: string; expMs: number } | null = null
let jwtFetchPromise: Promise<string | null> | null = null
export function clearApiJwtCache() {
jwtCache = null
jwtFetchPromise = null
}
/** Short-lived JWT for MailFlow API (Bearer). Cached until near expiry. */
export async function getApiJwt(): Promise<string | null> {
if (!isAppwriteClientConfigured()) {
return null
if (!isAppwriteClientConfigured()) return null
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) return null
const now = Date.now()
// Return cached token if still valid
if (jwtCache && jwtCache.expMs > now + 30_000) {
return jwtCache.token
}
if (isLocalViteAppwriteProxy() && !hasCookieFallbackSession()) {
return null
// If a fetch is already in progress, wait for it (no parallel requests)
if (jwtFetchPromise) {
return jwtFetchPromise
}
try {
const now = Date.now()
if (jwtCache && jwtCache.expMs > now + JWT_BUFFER_MS) {
return jwtCache.token
// Start a new fetch
jwtFetchPromise = (async () => {
try {
const res = await account.createJWT()
const token = res.jwt
jwtCache = { token, expMs: Date.now() + 14 * 60 * 1000 }
return token
} catch (err: unknown) {
// On 429: return cached token if we have one (even if expired)
if (jwtCache?.token && (err as { code?: number })?.code === 429) {
return jwtCache.token
}
jwtCache = null
return null
} finally {
jwtFetchPromise = null
}
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
}
})()
return jwtFetchPromise
}
// Auth helper functions

View File

@@ -2,6 +2,11 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { API_BASE } from './lib/api'
if (import.meta.env.DEV) {
console.info('[MailFlow] API_BASE =', API_BASE, '(muss zu server/.env PORT passen; Vite nach .env-Änderung neu starten)')
}
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
@@ -79,7 +79,7 @@ interface Digest {
}
export function Dashboard() {
const { user, logout } = useAuth()
const { user, logout, loading: authLoading } = useAuth()
const navigate = useNavigate()
const [stats, setStats] = useState<EmailStats | null>(null)
const [accounts, setAccounts] = useState<EmailAccount[]>([])
@@ -103,17 +103,11 @@ export function Dashboard() {
setTimeout(() => setMessage(null), 5000)
}
useEffect(() => {
if (user?.$id) {
loadData()
}
}, [user?.$id])
const loadData = async () => {
const loadData = useCallback(async () => {
if (!user?.$id) return
setLoading(true)
setError(null)
try {
const [statsRes, accountsRes, digestRes, subscriptionRes, referralRes] = await Promise.all([
api.getEmailStats(),
@@ -122,7 +116,7 @@ export function Dashboard() {
api.getSubscriptionStatus(),
api.getReferralCode().catch(() => ({ data: null })),
])
if (statsRes.data) setStats(statsRes.data)
if (accountsRes.data) setAccounts(accountsRes.data)
if (digestRes.data) setDigest(digestRes.data)
@@ -130,57 +124,83 @@ export function Dashboard() {
if (referralRes.data) setReferralCode(referralRes.data.referralCode)
} catch (err) {
console.error('Error loading dashboard data:', err)
setError('Couldnt load your data. Check your connection and refresh.')
setError('Couldnt load your data. Check your connection and refresh.')
} finally {
setLoading(false)
}
}
}, [user?.$id])
const handleSortNow = async () => {
if (!user?.$id || accounts.length === 0) {
setError('Connect your inbox first, then click Sort Now.')
useEffect(() => {
if (authLoading) return
if (!user?.$id) {
setLoading(false)
return
}
loadData()
}, [authLoading, user?.$id, loadData])
const handleSortNow = async () => {
if (!user?.$id) {
setError('You must be signed in to sort emails.')
return
}
const primary = accounts.find((a) => Boolean(a?.id))
if (!primary?.id) {
const msg =
'Please connect an email account first. Add Gmail or Outlook in Settings, then try again.'
setError(msg)
showMessage('error', msg)
return
}
setSorting(true)
setSortResult(null)
setError(null)
try {
const result = await api.sortEmails(accounts[0].id)
if (result.data) {
setSortResult(result.data)
// Track sort completed
trackSortCompleted(user.$id, result.data.sorted, result.data.isFirstRun || false)
// Refresh stats, digest, and subscription
const [statsRes, digestRes, subscriptionRes] = await Promise.all([
api.getEmailStats(),
api.getDigest(),
api.getSubscriptionStatus(),
])
if (statsRes.data) setStats(statsRes.data)
if (digestRes.data) setDigest(digestRes.data)
try {
const result = await api.sortEmails(primary.id)
if (result.error) {
if (result.error.code === 'LIMIT_REACHED') {
setError(result.error.message || 'Monthly limit reached')
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
const subscriptionRes = await api.getSubscriptionStatus()
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
} else if (result.error) {
// Check if it's a limit reached error
if (result.error.code === 'LIMIT_REACHED') {
setError(result.error.message || 'Monthly limit reached')
trackLimitReached(user.$id, result.error.limit || 500, result.error.used || 500)
// Refresh subscription to show updated usage
const subscriptionRes = await api.getSubscriptionStatus()
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
} else {
setError(result.error.message || 'Email sorting failed. Please try again or reconnect your account.')
}
} else {
setError(
result.error.message ||
'Email sorting failed. Please try again or reconnect your account.'
)
}
return
}
if (result.data) {
setSortResult(result.data)
showMessage(
'success',
`Sorted ${result.data.sorted} email${result.data.sorted === 1 ? '' : 's'}.`
)
trackSortCompleted(user.$id, result.data.sorted, result.data.isFirstRun || false)
const [statsRes, digestRes, subscriptionRes] = await Promise.all([
api.getEmailStats(),
api.getDigest(),
api.getSubscriptionStatus(),
])
if (statsRes.data) setStats(statsRes.data)
if (digestRes.data) setDigest(digestRes.data)
if (subscriptionRes.data) setSubscription(subscriptionRes.data)
} else {
setError('No data returned from the server. Please try again.')
}
} catch (err) {
console.error('Error sorting emails:', err)
setError('Something went wrong. Check your connection and try again.')
setError('Something went wrong. Check your connection and try again.')
} finally {
setSorting(false)
}
setSorting(false)
}
}
const handleLogout = async () => {
@@ -290,6 +310,43 @@ export function Dashboard() {
</header>
<main className="w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{!loading && accounts.length === 0 && (
<div
className="mb-6 p-4 rounded-xl border border-amber-200 dark:border-amber-800 bg-amber-50/90 dark:bg-amber-950/40 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
role="status"
aria-live="polite"
>
<div>
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
No email account connected
</p>
<p className="text-sm text-amber-800/90 dark:text-amber-300/90 mt-1">
Connect Gmail or Outlook to run sorting and see stats here.
</p>
</div>
<div className="flex flex-wrap gap-2 shrink-0">
<Button
size="sm"
onClick={() => navigate('/setup')}
className="bg-amber-600 hover:bg-amber-700 text-white"
aria-label="Connect email inbox"
>
<Plus className="w-4 h-4 mr-1.5" />
Connect inbox
</Button>
<Button
size="sm"
variant="outline"
onClick={() => navigate('/settings?tab=accounts')}
className="border-amber-300 dark:border-amber-700"
aria-label="Open account settings"
>
Account settings
</Button>
</div>
</div>
)}
{/* Dashboard Header */}
<div className="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>

View File

@@ -188,6 +188,8 @@ export function Settings() {
const [showImapForm, setShowImapForm] = useState(false)
const [imapForm, setImapForm] = useState({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
const [imapConnecting, setImapConnecting] = useState(false)
const [recoveringAccountId, setRecoveringAccountId] = useState<string | null>(null)
const [reSortingAccountId, setReSortingAccountId] = useState<string | null>(null)
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
const [newVipEmail, setNewVipEmail] = useState('')
const [subscription, setSubscription] = useState<Subscription | null>(null)
@@ -224,6 +226,7 @@ export function Settings() {
const [showNameLabelPanel, setShowNameLabelPanel] = useState(false)
const [referralData, setReferralData] = useState<{ referralCode: string; referralCount: number } | null>(null)
const [loadingReferral, setLoadingReferral] = useState(false)
const [resettingSort, setResettingSort] = useState(false)
// Control Panel Sub-Tabs
const [controlPanelTab, setControlPanelTab] = useState<'rules' | 'cleanup' | 'labels'>('rules')
@@ -612,6 +615,51 @@ export function Settings() {
}
}
const handleRecoverEmails = async (accountId: string) => {
if (!user?.$id) return
setRecoveringAccountId(accountId)
try {
const res = await api.recoverEmails(accountId)
if (res.error) {
showMessage('error', res.error.message || 'Recovery failed')
return
}
const data = res.data as { recovered?: number; message?: string } | undefined
const n = data?.recovered ?? 0
const text =
data?.message ||
(n > 0 ? `${n} emails recovered to inbox` : 'No emails found outside inbox')
showMessage('success', text)
} finally {
setRecoveringAccountId(null)
}
}
const handleReSortEmails = async (accountId: string) => {
if (!user?.$id) return
if (
!window.confirm(
'Move messages from Junk, Archive, MailFlow/EmailSorter folders (and similar sort targets) back to INBOX and remove MailFlow category tags? Then run Sort again. Sent/Drafts/Trash are not touched.',
)
) {
return
}
setReSortingAccountId(accountId)
try {
const res = await api.reSortEmails(accountId)
if (res.error) {
showMessage('error', res.error.message || 'Re-sort prep failed')
return
}
const data = res.data as
| { recovered?: number; mailFlowKeywordsStripped?: number; message?: string }
| undefined
showMessage('success', data?.message || 'Re-sort prep completed')
} finally {
setReSortingAccountId(null)
}
}
const handleConnectImap = async (e: React.FormEvent) => {
e.preventDefault()
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
@@ -1025,10 +1073,45 @@ export function Settings() {
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider === 'imap' ? 'IMAP' : account.provider}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap justify-end">
<Badge variant={account.connected ? 'success' : 'secondary'}>
{account.connected ? 'Connected' : 'Disconnected'}
</Badge>
{account.provider === 'imap' && (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={
recoveringAccountId === account.id ||
reSortingAccountId === account.id
}
onClick={() => handleRecoverEmails(account.id)}
>
{recoveringAccountId === account.id ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : null}
Recover Emails
</Button>
<Button
type="button"
variant="secondary"
size="sm"
disabled={
recoveringAccountId === account.id ||
reSortingAccountId === account.id
}
onClick={() => handleReSortEmails(account.id)}
title="Reset wrongly sorted mail: move from Junk/Archive/MailFlow folders to INBOX and clear MailFlow tags"
>
{reSortingAccountId === account.id ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : null}
Re-sort all
</Button>
</>
)}
<Button variant="ghost" size="icon" onClick={() => handleDisconnectAccount(account.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
@@ -2325,6 +2408,67 @@ export function Settings() {
{activeTab === 'name-labels' && isAdmin && (
<div className="space-y-6">
<Card className="border-amber-200 dark:border-amber-900">
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<CardTitle>Reset sort data (admin)</CardTitle>
</div>
<CardDescription>
Clears email stats, digests, usage, and onboarding progress for the chosen user. Removes the{' '}
<code className="text-xs">$MailFlow-sorted</code> flag from all messages in each IMAP account&apos;s INBOX.
Does not remove email connections or subscriptions.
</CardDescription>
</CardHeader>
<CardContent>
<Button
type="button"
variant="destructive"
disabled={resettingSort}
onClick={async () => {
const email = window.prompt('User email to reset:', 'support@webklar.com')
if (email == null) return
const trimmed = email.trim()
if (!trimmed) {
showMessage('error', 'Email is required')
return
}
if (
!window.confirm(
`Reset ALL sort-related data for ${trimmed}? This cannot be undone.`,
)
) {
return
}
setResettingSort(true)
try {
const res = await api.resetSortData(trimmed)
if (res.error) {
showMessage('error', res.error.message)
return
}
const d = res.data
showMessage(
'success',
`Reset OK. Stats/digests/usage cleared; IMAP $MailFlow-sorted removed from ${d?.imapCleared ?? 0} message(s).`,
)
} finally {
setResettingSort(false)
}
}}
>
{resettingSort ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Resetting
</>
) : (
'Reset sort data'
)}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">

12
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_DEV_BACKEND_ORIGIN?: string
readonly VITE_APPWRITE_ENDPOINT?: string
readonly VITE_APPWRITE_PROJECT_ID?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}