From b95c49daa53707aef0d4555fceb0cdf318ecd5d6 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 17 Mar 2026 10:12:09 +0100 Subject: [PATCH] =?UTF-8?q?feat(PROJ-2):=20EML/MBOX-Upload=20f=C3=BCr=20al?= =?UTF-8?q?le=20Benutzer=20zug=C3=A4nglich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: neue Routen POST /api/upload + GET /api/upload/{jobID}/progress (nur Auth, kein Admin) - api.ts: uploadMailFilesUser + getUploadProgressUser für /api/upload - search/page.tsx: Importieren-Button + Upload-Dialog mit Drag-and-Drop, Fortschrittsanzeige und automatischer Suchlisten-Aktualisierung nach Import Co-Authored-By: Claude Sonnet 4.6 --- internal/api/server.go | 4 ++ src/app/search/page.tsx | 147 +++++++++++++++++++++++++++++++++++++++- src/lib/api.ts | 19 ++++++ 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 9861bff..442bc7e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -114,6 +114,10 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/admin/upload", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpload))) s.mux.HandleFunc("GET /api/admin/upload/{jobID}/progress", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUploadProgress))) + // Upload routes (all authenticated users) + s.mux.HandleFunc("POST /api/upload", s.authMiddleware(s.handleUpload)) + s.mux.HandleFunc("GET /api/upload/{jobID}/progress", s.authMiddleware(s.handleUploadProgress)) + // IMAP routes (accessible to all authenticated users) s.mux.HandleFunc("GET /api/imap", s.authMiddleware(s.handleListImap)) s.mux.HandleFunc("POST /api/imap", s.authMiddleware(s.handleCreateImap)) diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 1a608e1..82529ba 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,14 +1,15 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; -import { searchEmails, exportMailsZIP, type SearchHit } from "@/lib/api"; +import { searchEmails, exportMailsZIP, uploadMailFilesUser, getUploadProgressUser, type SearchHit, type UploadJob } from "@/lib/api"; import { Navbar } from "@/components/navbar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; +import { Progress } from "@/components/ui/progress"; import { Dialog, DialogContent, @@ -68,6 +69,14 @@ export default function SearchPage() { const [exportAttachments, setExportAttachments] = useState(false); const [exporting, setExporting] = useState(false); + // Upload state + const [uploadOpen, setUploadOpen] = useState(false); + const [uploadDragging, setUploadDragging] = useState(false); + const [uploadJob, setUploadJob] = useState(null); + const [uploadError, setUploadError] = useState(""); + const [uploadLoading, setUploadLoading] = useState(false); + const uploadPollRef = useRef | null>(null); + // Clear selection when results change useEffect(() => { setSelected(new Set()); @@ -144,6 +153,54 @@ export default function SearchPage() { } } + async function handleUploadFiles(files: FileList | File[]) { + const allowed = Array.from(files).filter((f) => { + const n = f.name.toLowerCase(); + return n.endsWith(".eml") || n.endsWith(".mbox"); + }); + if (allowed.length === 0) { + setUploadError("Nur .eml und .mbox Dateien erlaubt."); + return; + } + setUploadError(""); + setUploadJob(null); + setUploadLoading(true); + try { + const { job_id } = await uploadMailFilesUser(allowed); + uploadPollRef.current = setInterval(async () => { + try { + const job = await getUploadProgressUser(job_id); + setUploadJob(job); + if (job.status !== "running") { + clearInterval(uploadPollRef.current!); + uploadPollRef.current = null; + setUploadLoading(false); + // Refresh search results after successful import + if (job.status === "done") doSearch(1); + } + } catch { + clearInterval(uploadPollRef.current!); + uploadPollRef.current = null; + setUploadLoading(false); + } + }, 1500); + } catch (e) { + setUploadError(e instanceof Error ? e.message : "Upload fehlgeschlagen."); + setUploadLoading(false); + } + } + + function handleUploadClose() { + if (uploadPollRef.current) { + clearInterval(uploadPollRef.current); + uploadPollRef.current = null; + } + setUploadOpen(false); + setUploadJob(null); + setUploadError(""); + setUploadLoading(false); + } + const totalPages = Math.ceil(total / PAGE_SIZE); const allSelected = results.length > 0 && results.every((h) => selected.has(h.id)); @@ -171,6 +228,9 @@ export default function SearchPage() { +
@@ -406,6 +466,89 @@ export default function SearchPage() { + {/* Upload Dialog */} + { if (!open) handleUploadClose(); else setUploadOpen(true); }}> + + + E-Mails importieren + + EML- oder MBOX-Dateien in das Archiv hochladen + + + + {!uploadJob && ( +
{ e.preventDefault(); setUploadDragging(true); }} + onDragLeave={() => setUploadDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setUploadDragging(false); + if (e.dataTransfer.files.length > 0) handleUploadFiles(e.dataTransfer.files); + }} + > +

+ Dateien hierher ziehen oder auswählen +

+ +

.eml · .mbox

+
+ )} + + {uploadError && ( +

{uploadError}

+ )} + + {uploadJob && ( +
+ 0 ? Math.round(((uploadJob.imported + uploadJob.skipped + uploadJob.errors) / uploadJob.total) * 100) : 0} + /> +
+
+
{uploadJob.imported}
+
Importiert
+
+
+
{uploadJob.skipped}
+
Duplikate
+
+
+
{uploadJob.errors}
+
Fehler
+
+
+ {uploadJob.status === "running" && ( +

+ Verarbeite {uploadJob.imported + uploadJob.skipped + uploadJob.errors} / {uploadJob.total} … +

+ )} + {uploadJob.status === "done" && ( +

Abgeschlossen

+ )} +
+ )} + + + + +
+
)}
diff --git a/src/lib/api.ts b/src/lib/api.ts index a0224e7..164aba7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -484,3 +484,22 @@ export async function uploadMailFiles(files: File[]): Promise<{ job_id: string } export async function getUploadProgress(jobID: string): Promise { return request(`/api/admin/upload/${jobID}/progress`); } + +export async function uploadMailFilesUser(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/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 getUploadProgressUser(jobID: string): Promise { + return request(`/api/upload/${jobID}/progress`); +}