diff --git a/features/INDEX.md b/features/INDEX.md index 30b8718..1f8dd73 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -85,7 +85,7 @@ | PROJ-67 | Manticore Search Upgrade 25.0.0 → 27.1.5 + Auto-Upgrade-Pfad | Deployed | [PROJ-67](PROJ-67-manticore-upgrade-25-zu-27.md) | 2026-07-05 | | PROJ-68 | sudo-Provisionierung für Admin-Dienststeuerung fehlte komplett | Deployed | [PROJ-68](PROJ-68-sudo-provisionierung-dienststeuerung.md) | 2026-07-05 | | PROJ-69 | Admin-Dashboard Tab-Gruppierung (2-Ebenen-Navigation) | Planned | [PROJ-69](PROJ-69-admin-tabs-gruppierung.md) | 2026-07-06 | -| PROJ-70 | User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach) | In Progress | [PROJ-70](PROJ-70-imap-rueckholung-self-service.md) | 2026-07-07 | +| PROJ-70 | User-Self-Service IMAP-Rückholung (Archiv-Mail zurück ins Postfach) | In Review | [PROJ-70](PROJ-70-imap-rueckholung-self-service.md) | 2026-07-07 | diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx index c50214d..99c84d1 100644 --- a/src/app/mail/[id]/page.tsx +++ b/src/app/mail/[id]/page.tsx @@ -22,6 +22,7 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { OcrBadge } from "@/components/ocr-badge"; +import { RestoreMailButton } from "@/components/mail/RestoreMailButton"; import { FileText } from "lucide-react"; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -410,6 +411,10 @@ export default function MailViewPage({ {ocrLoading ? "..." : "OCR-Text"} )} + )} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 453dd9f..9679281 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -11,6 +11,7 @@ import { ProfileSection } from "@/components/settings/ProfileSection"; import { DisplaySection } from "@/components/settings/DisplaySection"; import { TotpSection } from "@/components/settings/TotpSection"; import { ImapSection } from "@/components/settings/ImapSection"; +import { ImapRestoreSection } from "@/components/settings/ImapRestoreSection"; export default function SettingsPage() { const { user, loading, refresh } = useAuth(); @@ -50,6 +51,12 @@ export default function SettingsPage() { systemInfoLoading={systemInfoLoading} username={user.username} /> + { + void refresh(); + }} + /> ); diff --git a/src/components/mail/RestoreMailButton.tsx b/src/components/mail/RestoreMailButton.tsx new file mode 100644 index 0000000..8dee5ec --- /dev/null +++ b/src/components/mail/RestoreMailButton.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { MailPlus } from "lucide-react"; +import { getImapAccounts, restoreMail, type ImapAccount } from "@/lib/api"; + +interface RestoreMailButtonProps { + mailId: string; + // PROJ-70: gate visibility on the user's opt-in flag. + enabled: boolean; +} + +// PROJ-70: "Zurück ins Postfach" — copies an archived mail into the user's +// external IMAP mailbox (INBOX). Only rendered when the opt-in is active and +// at least one IMAP account is configured. +export function RestoreMailButton({ mailId, enabled }: RestoreMailButtonProps) { + const [accounts, setAccounts] = useState([]); + const [accountsLoaded, setAccountsLoaded] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [selected, setSelected] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + useEffect(() => { + if (!enabled) return; + let active = true; + getImapAccounts() + .then((data) => { + if (!active) return; + setAccounts(data); + if (data.length > 0) setSelected(String(data[0].id)); + }) + .catch(() => { + // Ignore — without accounts the button stays hidden. + }) + .finally(() => { + if (active) setAccountsLoaded(true); + }); + return () => { + active = false; + }; + }, [enabled]); + + // Hidden until we know the opt-in is on and there is a target mailbox. + if (!enabled || !accountsLoaded || accounts.length === 0) { + return null; + } + + function openDialog() { + setError(""); + setDialogOpen(true); + } + + async function handleRestore() { + const accountId = parseInt(selected, 10); + if (!accountId) { + setError("Bitte ein Ziel-Postfach auswählen."); + return; + } + setLoading(true); + setError(""); + setSuccess(""); + try { + const res = await restoreMail(mailId, accountId); + const acc = accounts.find((a) => a.id === res.account); + setSuccess( + `Mail wurde nach ${res.mailbox} in "${acc?.name ?? res.account}" zurückgeholt.` + ); + setDialogOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Rückholung fehlgeschlagen."); + } finally { + setLoading(false); + } + } + + return ( + <> + + + {success && ( + + {success} + + )} + {error && !dialogOpen && ( + + {error} + + )} + + + + + Mail zurück ins Postfach + + Es wird eine Kopie dieser archivierten Mail in den Ordner INBOX + des gewählten Postfachs übertragen. Das Archiv bleibt unverändert. + + + +
+ {accounts.length > 1 ? ( +
+ + +
+ ) : ( +

+ Ziel-Postfach: {accounts[0].name} ({accounts[0].username}) +

+ )} + + {error && ( + + {error} + + )} +
+ + + + + +
+
+ + ); +} diff --git a/src/components/settings/ImapRestoreSection.tsx b/src/components/settings/ImapRestoreSection.tsx new file mode 100644 index 0000000..9f27809 --- /dev/null +++ b/src/components/settings/ImapRestoreSection.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { MailPlus } from "lucide-react"; +import { useImapRestore } from "@/hooks/useImapRestore"; + +interface ImapRestoreSectionProps { + initialEnabled: boolean; + onChanged: (enabled: boolean) => void; +} + +// PROJ-70: Opt-in toggle that lets a user restore archived mails back into +// their own external IMAP mailbox. Off by default; enabling requires the +// current login password. +export function ImapRestoreSection({ + initialEnabled, + onChanged, +}: ImapRestoreSectionProps) { + const { + enabled, + dialogOpen, + setDialogOpen, + password, + setPassword, + error, + success, + loading, + handleToggle, + confirmEnable, + cancelDialog, + } = useImapRestore(initialEnabled, onChanged); + + return ( + + + + + + +

+ Wenn aktiviert, können Sie einzelne archivierte Mails aus der + Detailansicht als Kopie zurück in Ihr eigenes IMAP-Postfach (Ordner + INBOX) übertragen. Das Archiv selbst bleibt unverändert. Standardmäßig + ist diese Funktion deaktiviert. +

+ +
+
+ +

+ {enabled ? "Aktiv" : "Deaktiviert"} +

+
+ +
+ + {success && ( + + {success} + + )} + {error && !dialogOpen && ( + + {error} + + )} +
+ + { + if (!open) cancelDialog(); + else setDialogOpen(true); + }} + > + +
+ + Rückholung aktivieren + + Bitte bestätigen Sie mit Ihrem aktuellen Passwort, um die + Rückholung ins Postfach zu aktivieren. + + +
+ + setPassword(e.target.value)} + autoComplete="current-password" + autoFocus + aria-label="Aktuelles Passwort" + /> + {error && ( + + {error} + + )} +
+ + + + +
+
+
+
+ ); +} diff --git a/src/hooks/useImapRestore.ts b/src/hooks/useImapRestore.ts new file mode 100644 index 0000000..d0da4c2 --- /dev/null +++ b/src/hooks/useImapRestore.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { setImapRestore } from "@/lib/api"; + +// PROJ-70: Manages the IMAP-restore opt-in toggle. +// Enabling requires the current password (bcrypt re-check on the server); +// disabling is applied immediately without a password. +export function useImapRestore( + initialEnabled: boolean, + onChanged: (enabled: boolean) => void +) { + const [enabled, setEnabled] = useState(initialEnabled); + const [dialogOpen, setDialogOpen] = useState(false); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [loading, setLoading] = useState(false); + + // Triggered by the Switch. Turning ON opens the password dialog first; + // turning OFF is sent directly. + function handleToggle(next: boolean) { + setError(""); + setSuccess(""); + if (next) { + setPassword(""); + setDialogOpen(true); + } else { + void disable(); + } + } + + async function disable() { + setLoading(true); + try { + const res = await setImapRestore(false, ""); + setEnabled(res.imap_restore_enabled); + onChanged(res.imap_restore_enabled); + setSuccess("Rückholung deaktiviert."); + } catch (err) { + setError(err instanceof Error ? err.message : "Deaktivieren fehlgeschlagen."); + // Keep the switch reflecting the actual (still enabled) server state. + setEnabled(true); + } finally { + setLoading(false); + } + } + + async function confirmEnable(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (!password) { + setError("Bitte aktuelles Passwort eingeben."); + return; + } + setLoading(true); + try { + const res = await setImapRestore(true, password); + setEnabled(res.imap_restore_enabled); + onChanged(res.imap_restore_enabled); + setDialogOpen(false); + setPassword(""); + setSuccess("Rückholung aktiviert."); + } catch (err) { + // Wrong password / LDAP account / other → keep dialog open, switch stays OFF. + setError(err instanceof Error ? err.message : "Aktivieren fehlgeschlagen."); + setEnabled(false); + } finally { + setLoading(false); + } + } + + function cancelDialog() { + setDialogOpen(false); + setPassword(""); + setError(""); + setEnabled(false); + } + + return { + enabled, + dialogOpen, + setDialogOpen, + password, + setPassword, + error, + success, + loading, + handleToggle, + confirmEnable, + cancelDialog, + }; +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 4b0e09e..630ea00 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -15,6 +15,7 @@ export { getMe, logout, changePassword, + setImapRestore, changeEmail, updatePreferences, getUsers, @@ -92,6 +93,7 @@ export type { Pop3Account, Pop3TestResult, UploadJob, + RestoreResult, } from "./mail"; export { searchEmails, @@ -99,6 +101,7 @@ export { getThread, downloadMailAttachment, downloadMailRaw, + restoreMail, getOCRTextDownloadURL, downloadMailOCRText, getImapAccounts, diff --git a/src/lib/api/mail.ts b/src/lib/api/mail.ts index 51bc509..f8674f0 100644 --- a/src/lib/api/mail.ts +++ b/src/lib/api/mail.ts @@ -238,6 +238,45 @@ export async function downloadMailOCRText( return { kind: "ok", blob: await res.blob(), filename }; } +// ── Restore (PROJ-70) ─────────────────────────────────────────────────────── + +export interface RestoreResult { + ok: boolean; + mailbox: string; + account: number; +} + +/** + * PROJ-70: Copy an archived mail back into the user's external IMAP mailbox + * (fixed target folder INBOX). The archive stays read-only/unchanged. + * Surfaces the server's plain-text `error` field (e.g. 422 target rejected). + */ +export async function restoreMail( + id: string, + accountId: number +): Promise { + const res = await fetch(`${API_BASE}/api/mails/${id}/restore`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ account_id: accountId }), + }); + if (res.ok) { + return res.json(); + } + // Try to extract a human-readable error message from the JSON body. + let message = `Rückholung fehlgeschlagen (${res.status})`; + try { + const body = await res.json(); + if (body && typeof body.error === "string" && body.error) { + message = body.error; + } + } catch { + // non-JSON body, keep the default message + } + throw new Error(message); +} + // ── IMAP ────────────────────────────────────────────────────────────────────── export async function getImapAccounts(): Promise { diff --git a/src/lib/api/users.ts b/src/lib/api/users.ts index 6bf28ad..97bcae3 100644 --- a/src/lib/api/users.ts +++ b/src/lib/api/users.ts @@ -28,6 +28,8 @@ export interface MeResponse { role: string; email: string; list_page_size: number; + // PROJ-70: Opt-in flag for IMAP restore (mail back to external mailbox) + imap_restore_enabled?: boolean; } export interface CreateUserRequest { @@ -75,6 +77,21 @@ export async function changePassword( }); } +// PROJ-70: Toggle the IMAP-restore opt-in. Enabling requires the current +// password (bcrypt re-check on the server); disabling does not. +export async function setImapRestore( + enabled: boolean, + currentPassword: string +): Promise<{ ok: boolean; imap_restore_enabled: boolean }> { + return request<{ ok: boolean; imap_restore_enabled: boolean }>( + "/api/auth/imap-restore", + { + method: "PATCH", + body: JSON.stringify({ enabled, current_password: currentPassword }), + } + ); +} + export async function changeEmail( email: string ): Promise<{ ok: boolean; email: string }> {