docs: vollständige README, PROJ-2 Web-Upload, PROJ-19 Mailpiler-Migration
README.md:
- Vollständige Dokumentation aller implementierten Funktionen
- Konfigurationsreferenz, Installation, Systemd, REST-API-Übersicht
- In-Progress-Features klar gekennzeichnet
PROJ-2 (EML/MBOX Web-Upload):
- POST /api/admin/upload – Multipart-Upload mit Hintergrund-Job
- GET /api/admin/upload/{jobID}/progress – Polling
- Admin-Tab "Import" mit Drag-and-Drop, Fortschrittsbalken, Abschlussbericht
PROJ-19 (Mailpiler Migration):
- archivmail import-piler mit Methoden: pilerexport | direct | auto
- Direct: AES-256-CBC + zlib mit defensiven Fallbacks
- pilerexport: Wrapper um mailpilers Export-Tool
Status-Updates: PROJ-3, PROJ-4, PROJ-6, PROJ-7, PROJ-10, PROJ-11 → Deployed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,12 +15,15 @@ import {
|
||||
getServices,
|
||||
serviceAction,
|
||||
getSystemStats,
|
||||
uploadMailFiles,
|
||||
getUploadProgress,
|
||||
type User,
|
||||
type AuditEntry,
|
||||
type SMTPStatus,
|
||||
type StorageStats,
|
||||
type ServiceStatus,
|
||||
type SystemStats,
|
||||
type UploadJob,
|
||||
} from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -111,6 +114,13 @@ export default function AdminPage() {
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
|
||||
// Upload state
|
||||
const [uploadDragging, setUploadDragging] = useState(false);
|
||||
const [uploadJob, setUploadJob] = useState<UploadJob | null>(null);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [uploadLoading, setUploadLoading] = useState(false);
|
||||
const uploadPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setDashLoading(true);
|
||||
try {
|
||||
@@ -170,6 +180,40 @@ export default function AdminPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleUploadFiles(files: File[]) {
|
||||
const valid = files.filter(f => f.name.toLowerCase().endsWith(".eml") || f.name.toLowerCase().endsWith(".mbox"));
|
||||
if (valid.length === 0) {
|
||||
setUploadError("Nur .eml und .mbox Dateien erlaubt.");
|
||||
return;
|
||||
}
|
||||
setUploadError("");
|
||||
setUploadJob(null);
|
||||
setUploadLoading(true);
|
||||
try {
|
||||
const { job_id } = await uploadMailFiles(valid);
|
||||
// Start polling
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const job = await getUploadProgress(job_id);
|
||||
setUploadJob(job);
|
||||
if (job.status !== "running") {
|
||||
clearInterval(poll);
|
||||
uploadPollRef.current = null;
|
||||
setUploadLoading(false);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(poll);
|
||||
uploadPollRef.current = null;
|
||||
setUploadLoading(false);
|
||||
}
|
||||
}, 1500);
|
||||
uploadPollRef.current = poll;
|
||||
} catch (e: unknown) {
|
||||
setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen.");
|
||||
setUploadLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleServiceAction(name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") {
|
||||
setServiceActionLoading(`${name}:${action}`);
|
||||
setServiceError("");
|
||||
@@ -296,6 +340,7 @@ export default function AdminPage() {
|
||||
<TabsTrigger value="services">Dienste</TabsTrigger>
|
||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
<TabsTrigger value="modules">Module</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -1019,6 +1064,102 @@ export default function AdminPage() {
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
{/* ── Import ── */}
|
||||
<TabsContent value="import" className="mt-4 space-y-4">
|
||||
<h2 className="text-lg font-semibold">EML / MBOX importieren</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Lade .eml oder .mbox Dateien hoch um sie ins Archiv zu importieren. Duplikate werden automatisch übersprungen.
|
||||
</p>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); setUploadDragging(true); }}
|
||||
onDragLeave={() => setUploadDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setUploadDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
handleUploadFiles(files);
|
||||
}}
|
||||
className={`border-2 border-dashed rounded-lg p-10 text-center transition-colors cursor-pointer ${
|
||||
uploadDragging ? "border-primary bg-primary/5" : "border-muted-foreground/25 hover:border-primary/50"
|
||||
}`}
|
||||
onClick={() => document.getElementById("upload-file-input")?.click()}
|
||||
>
|
||||
<input
|
||||
id="upload-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".eml,.mbox"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) handleUploadFiles(Array.from(e.target.files));
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm font-medium">Dateien hierher ziehen oder klicken zum Auswählen</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Akzeptiert: .eml, .mbox</p>
|
||||
</div>
|
||||
|
||||
{uploadError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{uploadError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
{(uploadLoading || uploadJob) && uploadJob && (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{uploadJob.status === "running" ? "Import läuft..." : "Import abgeschlossen"}
|
||||
</span>
|
||||
<Badge variant={uploadJob.status === "done" ? "default" : "secondary"}>
|
||||
{uploadJob.status === "done" ? "Fertig" : "Läuft"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{uploadJob.total > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(100, ((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} verarbeitet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadJob.status === "done" && (
|
||||
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
||||
<div className="rounded bg-green-50 dark:bg-green-950 p-2">
|
||||
<p className="font-bold text-green-700 dark:text-green-400">{uploadJob.imported}</p>
|
||||
<p className="text-xs text-muted-foreground">Importiert</p>
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 dark:bg-yellow-950 p-2">
|
||||
<p className="font-bold text-yellow-700 dark:text-yellow-400">{uploadJob.skipped}</p>
|
||||
<p className="text-xs text-muted-foreground">Übersprungen</p>
|
||||
</div>
|
||||
<div className="rounded bg-red-50 dark:bg-red-950 p-2">
|
||||
<p className="font-bold text-red-700 dark:text-red-400">{uploadJob.errors}</p>
|
||||
<p className="text-xs text-muted-foreground">Fehler</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{uploadLoading && !uploadJob && (
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Upload läuft...</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="modules" className="mt-4">
|
||||
<ModulesTab />
|
||||
</TabsContent>
|
||||
|
||||
@@ -453,3 +453,34 @@ export async function exportMailsZIP(ids: string[], attachments: boolean): Promi
|
||||
if (!res.ok) throw new Error("ZIP export failed");
|
||||
return { blob: await res.blob() };
|
||||
}
|
||||
|
||||
// ── Upload ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UploadJob {
|
||||
id: string;
|
||||
status: "running" | "done" | "error";
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: number;
|
||||
error_msg?: string;
|
||||
}
|
||||
|
||||
export async function uploadMailFiles(files: File[]): Promise<{ job_id: string }> {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append("files", f);
|
||||
const res = await fetch(`${API_BASE}/api/admin/upload`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(body || `Upload failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getUploadProgress(jobID: string): Promise<UploadJob> {
|
||||
return request<UploadJob>(`/api/admin/upload/${jobID}/progress`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user