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:
sysops
2026-03-17 09:23:34 +01:00
parent 31de5ec99c
commit 7c29ee88bd
16 changed files with 1726 additions and 91 deletions
+141
View File
@@ -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>
+31
View File
@@ -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`);
}