Try
dfssdfsfdsf
This commit is contained in:
@@ -16,10 +16,10 @@ VITE_APPWRITE_PROJECT_ID=
|
||||
# APPWRITE_DEV_ORIGIN=
|
||||
# VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
|
||||
# Backend: leer = Browser ruft /api auf (Vite-Proxy → 127.0.0.1:3000/api/…).
|
||||
# Nicht VITE_APPWRITE_ENDPOINT (/v1) als VITE_API_URL verwenden — sonst 404.
|
||||
# Wenn /api 404: nur EIN Vite auf 5173 (strictPort) — anderen Prozess auf 5173 beenden oder FRONTEND_URL anpassen.
|
||||
# Wenn /api im Browser 404 liefert: PORT prüfen und ggf. Proxy-Ziel setzen:
|
||||
# VITE_DEV_API_ORIGIN=http://127.0.0.1:3000
|
||||
# Optional direkt (ohne Proxy): http://localhost:3000 — fehlendes /api wird ergänzt.
|
||||
# VITE_API_URL=
|
||||
# Backend lokal (empfohlen, gleicher Port wie ../server/.env PORT):
|
||||
# VITE_DEV_API_ORIGIN = nur Origin, kein /api
|
||||
# VITE_API_URL = gleicher Host:Port; /api wird in api.ts angehängt
|
||||
VITE_DEV_API_ORIGIN=http://127.0.0.1:3030
|
||||
VITE_API_URL=http://127.0.0.1:3030
|
||||
# Alternativ nur Proxy (relativ): VITE_API_URL=/api und VITE_DEV_API_ORIGIN weglassen — dann muss der Vite-Proxy zum Backend passen.
|
||||
# Nicht VITE_APPWRITE_ENDPOINT (/v1) als VITE_API_URL setzen — sonst 404.
|
||||
|
||||
@@ -6,5 +6,5 @@ VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||
VITE_APPWRITE_PROJECT_ID=your-project-id
|
||||
|
||||
# OAuth URLs (generated by your backend)
|
||||
VITE_GMAIL_OAUTH_URL=http://localhost:3000/api/oauth/gmail
|
||||
VITE_OUTLOOK_OAUTH_URL=http://localhost:3000/api/oauth/outlook
|
||||
VITE_GMAIL_OAUTH_URL=http://localhost:3030/api/oauth/gmail
|
||||
VITE_OUTLOOK_OAUTH_URL=http://localhost:3030/api/oauth/outlook
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('Couldn’t load your data. Check your connection and refresh.')
|
||||
setError('Couldn’t 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>
|
||||
|
||||
@@ -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'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
12
client/src/vite-env.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -2,16 +2,54 @@ import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
/** Same default as server when `server/.env` has no PORT (see repo root `mailflow.dev.port.json`). */
|
||||
function readMailflowDevPort(): string {
|
||||
const f = path.resolve(__dirname, '../mailflow.dev.port.json')
|
||||
try {
|
||||
const j = JSON.parse(fs.readFileSync(f, 'utf8')) as { port?: number }
|
||||
if (j.port != null && Number.isFinite(Number(j.port))) return String(j.port)
|
||||
} catch {
|
||||
/* missing or invalid */
|
||||
}
|
||||
return '3030'
|
||||
}
|
||||
|
||||
/** Align Vite proxy with server/.env PORT (client/.env does not load server PORT). */
|
||||
function readBackendPortFromServerEnv(): string {
|
||||
const envFile = path.resolve(__dirname, '../server/.env')
|
||||
try {
|
||||
let text = fs.readFileSync(envFile, 'utf8')
|
||||
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
if (/^\s*#/.test(line)) continue
|
||||
const noComment = line.split('#')[0]?.trim() || ''
|
||||
const m = /^\s*PORT\s*=\s*"?(\d+)"?/i.exec(noComment)
|
||||
if (m) return m[1]
|
||||
}
|
||||
} catch {
|
||||
/* no server/.env */
|
||||
}
|
||||
return readMailflowDevPort()
|
||||
}
|
||||
|
||||
/** Origin only (no trailing /api) — matches client api.ts / VITE_DEV_BACKEND_ORIGIN contract. */
|
||||
function stripTrailingApiFromOrigin(o: string): string {
|
||||
let s = o.replace(/\/$/, '')
|
||||
while (s.endsWith('/api')) {
|
||||
s = s.slice(0, -4).replace(/\/+$/, '')
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, '')
|
||||
const appwriteDevOrigin = (env.APPWRITE_DEV_ORIGIN || '').replace(/\/$/, '')
|
||||
const defaultApiOrigin = `http://127.0.0.1:${readBackendPortFromServerEnv()}`
|
||||
// 127.0.0.1 avoids Windows localhost → IPv6 (::1) vs backend listening on IPv4-only
|
||||
const apiDevTarget = (env.VITE_DEV_API_ORIGIN || 'http://127.0.0.1:3000').replace(
|
||||
/\/$/,
|
||||
''
|
||||
)
|
||||
const apiDevTarget = stripTrailingApiFromOrigin(env.VITE_DEV_API_ORIGIN || defaultApiOrigin)
|
||||
|
||||
const proxy: Record<
|
||||
string,
|
||||
@@ -37,6 +75,13 @@ export default defineConfig(({ mode }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
// Browser ruft Express direkt an (CORS) — vermeidet 404, wenn der Vite-/api-Proxy nicht greift
|
||||
define:
|
||||
mode === 'development'
|
||||
? {
|
||||
'import.meta.env.VITE_DEV_BACKEND_ORIGIN': JSON.stringify(apiDevTarget),
|
||||
}
|
||||
: {},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -44,10 +89,13 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
// Wenn 5173 schon belegt ist, nicht still auf einen anderen Port wechseln — sonst öffnet man oft noch die alte URL und bekommt für /api 404.
|
||||
strictPort: true,
|
||||
proxy,
|
||||
},
|
||||
// `vite preview` hat standardmäßig keinen Dev-Proxy — sonst wäre /api ein 404.
|
||||
preview: { proxy },
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user