feat(PROJ-30): Xapian → Manticore Search Migration

- internal/index/manticore.go: ManticoreTenantManager + manticoreIndex (RT-Indizes, CGO-frei)
- internal/index/index.go: TenantIndexer Interface (Xapian + Manticore)
- internal/index/tenant_worker.go: mgr-Typ auf TenantIndexer Interface
- internal/api/server.go: idxMgr auf TenantIndexer Interface
- config/config.go: IndexConfig.ManticoreDSN Feld
- cmd/archivmail/cmd_reindex.go: reindex Subkommando
- cmd/archivmail/main.go: Manticore-Branch + reindex Case
- go.mod: github.com/go-sql-driver/mysql v1.8.1
- update.sh: Manticore auto-install, CGO_ENABLED=0, config.yml migration, auto-reindex

fix(IMAP): TCP-Deadline-Wrapper für steckengebliebene Imports
fix(auth): Email-Claim in JWT für User-Isolation
fix(search): User-Isolation via sess.Email (fail-safe)
fix(ui): Admin-Login Auth-Cache, Logout-Redirect, IMAP-Polling-Resilienz

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-03 21:19:36 +02:00
parent e90d588e30
commit a93a843506
19 changed files with 742 additions and 65 deletions
+3 -1
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { login } from "@/lib/api";
import { getCachedUser, setCachedUser } from "@/lib/auth-cache";
import { getCachedUser, setCachedUser, clearAuthCache } from "@/lib/auth-cache";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -31,6 +31,7 @@ export default function AdminLoginPage() {
setLoading(true);
try {
clearAuthCache();
const res = await login(username, password);
const role = res?.user?.role ?? "";
if (!ADMIN_ROLES.includes(role)) {
@@ -38,6 +39,7 @@ export default function AdminLoginPage() {
setError("Kein Zugriff. Dieses Login ist nur für Admins und Auditoren.");
return;
}
setCachedUser({ username: res.user.username, email: res.user.email, role });
if (role === "auditor") {
router.push("/search");
} else {
+30 -4
View File
@@ -79,8 +79,12 @@ export default function ImapPage() {
// Saving state
const [saving, setSaving] = useState(false);
// Import error state
const [importError, setImportError] = useState<string>("");
// Polling refs
const pollingRefs = useRef<Map<number, ReturnType<typeof setInterval>>>(new Map());
const pollErrorCount = useRef<Map<number, number>>(new Map());
const loadAccounts = useCallback(async () => {
try {
@@ -102,19 +106,28 @@ export default function ImapPage() {
for (const acc of accounts) {
const isActive = acc.status === "running" || acc.sync_running;
if (isActive && !pollingRefs.current.has(acc.id)) {
pollErrorCount.current.set(acc.id, 0);
const interval = setInterval(async () => {
try {
const updated = await getImapProgress(acc.id);
pollErrorCount.current.set(acc.id, 0);
setAccounts((prev) =>
prev.map((a) => (a.id === updated.id ? updated : a))
);
if (updated.status !== "running" && !updated.sync_running) {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
pollErrorCount.current.delete(acc.id);
}
} catch {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
// Only stop polling after 5 consecutive failures (tolerates brief network hiccups)
const errors = (pollErrorCount.current.get(acc.id) ?? 0) + 1;
pollErrorCount.current.set(acc.id, errors);
if (errors >= 5) {
clearInterval(pollingRefs.current.get(acc.id)!);
pollingRefs.current.delete(acc.id);
pollErrorCount.current.delete(acc.id);
}
}
}, 2000);
pollingRefs.current.set(acc.id, interval);
@@ -203,11 +216,12 @@ export default function ImapPage() {
}
async function handleStartImport(id: number) {
setImportError("");
try {
const updated = await startImapImport(id);
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
} catch {
// ignore
} catch (err) {
setImportError(err instanceof Error ? err.message : "Import konnte nicht gestartet werden.");
}
}
@@ -336,6 +350,10 @@ export default function ImapPage() {
</Button>
</div>
{importError && (
<p className="mb-4 text-sm text-destructive" role="alert">{importError}</p>
)}
{loading ? (
<div className="space-y-4">
{[1, 2].map((i) => (
@@ -363,6 +381,14 @@ export default function ImapPage() {
{statusBadge(acc.status)}
</CardHeader>
<CardContent>
{acc.status === "running" && acc.progress_total === 0 && (
<div className="mb-3 space-y-1">
<Progress value={undefined} className="animate-pulse" />
<p className="text-xs text-muted-foreground">
Zaehle E-Mails auf dem Server...
</p>
</div>
)}
{acc.status === "running" && acc.progress_total > 0 && (
<div className="mb-3 space-y-1">
<Progress
+14 -3
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { login } from "@/lib/api";
import { getCachedUser } from "@/lib/auth-cache";
import { getCachedUser, setCachedUser, clearAuthCache } from "@/lib/auth-cache";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -32,12 +32,14 @@ export default function LoginPage() {
setLoading(true);
try {
clearAuthCache();
const res = await login(username, password);
const role = res?.user?.role ?? "";
if (ADMIN_ROLES.includes(role)) {
setError("Admins und Auditoren bitte über /admin anmelden.");
setError("ADMIN_REDIRECT");
return;
}
setCachedUser({ username: res.user.username, email: res.user.email, role });
router.push("/search");
} catch {
setError("Anmeldung fehlgeschlagen. Bitte Zugangsdaten pruefen.");
@@ -83,11 +85,20 @@ export default function LoginPage() {
aria-label="Passwort"
/>
</div>
{error && (
{error && error !== "ADMIN_REDIRECT" && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{error === "ADMIN_REDIRECT" && (
<p className="text-sm text-destructive" role="alert">
Admins und Auditoren bitte{" "}
<a href="/admin/login" className="underline font-medium">
hier anmelden
</a>
.
</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Anmelden..." : "Anmelden"}
</Button>
+3 -1
View File
@@ -21,13 +21,15 @@ interface UserNavProps {
export function UserNav({ username, role }: UserNavProps) {
const router = useRouter();
const ADMIN_ROLES = ["auditor", "admin", "domain_admin", "superadmin"];
async function handleLogout() {
try {
await logout();
} catch {
// ignore logout errors
}
router.push("/");
router.push(ADMIN_ROLES.includes(role) ? "/admin/login" : "/");
}
return (