feat(PROJ-33): IMAP UID-Stabilität + Shared/Personal-Modus

Backend:
- storage: uid BIGSERIAL Migration, MailWithUID, GetMailsWithUID, GetMailsByRecipient
- tenantstore: imap_mode Spalte, GetIMAPMode, SetIMAPMode
- imapserver: stable UIDs aus DB, personal/shared Modus, userEmail in session
- api: GET/PUT /api/admin/settings/imap-mode (domain_admin only, double opt-in)

Frontend:
- IMAPSettingsTab: Modus-Anzeige + Toggle mit Double-Opt-In Dialog
- Admin-Panel: IMAP-Tab für domain_admin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 09:46:52 +02:00
parent b6856af2eb
commit 8d0f685fc9
9 changed files with 425 additions and 53 deletions
+10
View File
@@ -75,6 +75,7 @@ import { TenantsTab } from "@/components/admin/tabs/TenantsTab";
import { LabelsTab } from "@/components/admin/tabs/LabelsTab";
import { CertTab } from "@/components/admin/tabs/CertTab";
import { ModulesTab } from "@/components/admin/ModulesTab";
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
const AUDIT_PAGE_SIZE = 25;
@@ -799,6 +800,9 @@ export default function AdminPage() {
{!isSuperAdmin && user?.role === "domain_admin" && (
<TabsTrigger value="tenant-ldap" onClick={loadTenantLDAP}>LDAP</TabsTrigger>
)}
{!isSuperAdmin && user?.role === "domain_admin" && (
<TabsTrigger value="imap-settings">IMAP</TabsTrigger>
)}
{isSuperAdmin && <TabsTrigger value="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
@@ -1075,6 +1079,12 @@ export default function AdminPage() {
</TabsContent>
)}
{!isSuperAdmin && (
<TabsContent value="imap-settings">
<IMAPSettingsTab />
</TabsContent>
)}
<TabsContent value="modules" className="mt-4">
<ModulesTab />
</TabsContent>
@@ -0,0 +1,166 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
async function getIMAPMode(): Promise<string> {
const res = await fetch("/api/admin/settings/imap-mode", { credentials: "include" });
if (!res.ok) throw new Error("Fehler beim Laden");
const data = await res.json();
return data.mode;
}
async function setIMAPMode(mode: string, confirmed: boolean): Promise<void> {
const res = await fetch("/api/admin/settings/imap-mode", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, confirmed }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string }).error ?? "Fehler beim Speichern");
}
}
export function IMAPSettingsTab() {
const [mode, setMode] = useState<string>("personal");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [sharedDialogOpen, setSharedDialogOpen] = useState(false);
const [confirmed, setConfirmed] = useState(false);
useEffect(() => {
getIMAPMode()
.then(setMode)
.catch(() => setError("IMAP-Modus konnte nicht geladen werden"))
.finally(() => setLoading(false));
}, []);
const handleSetPersonal = async () => {
setSaving(true);
setError("");
try {
await setIMAPMode("personal", false);
setMode("personal");
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler");
} finally {
setSaving(false);
}
};
const handleConfirmShared = async () => {
if (!confirmed) return;
setSaving(true);
setError("");
try {
await setIMAPMode("shared", true);
setMode("shared");
setSharedDialogOpen(false);
setConfirmed(false);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler");
} finally {
setSaving(false);
}
};
return (
<div className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>IMAP-Zugriffsmodus</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<p className="text-muted-foreground text-sm">Lädt...</p>
) : (
<>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Aktueller Modus:</span>
<Badge variant={mode === "shared" ? "destructive" : "default"}>
{mode === "shared" ? "Gemeinsames Archiv" : "Persönlicher Posteingang"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{mode === "personal"
? "Jeder Nutzer sieht über IMAP nur seine eigenen archivierten Mails (gefiltert nach E-Mail-Adresse)."
: "Alle Nutzer dieses Mandanten sehen über IMAP alle archivierten Mails des Mandanten."}
</p>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2">
{mode !== "personal" && (
<Button variant="outline" disabled={saving} onClick={handleSetPersonal}>
{saving ? "Speichern..." : "Auf Persönlich wechseln"}
</Button>
)}
{mode !== "shared" && (
<Button
variant="destructive"
disabled={saving}
onClick={() => {
setSharedDialogOpen(true);
setConfirmed(false);
}}
>
Gemeinsames Archiv aktivieren
</Button>
)}
</div>
</>
)}
</CardContent>
</Card>
<Dialog open={sharedDialogOpen} onOpenChange={setSharedDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gemeinsames Archiv aktivieren?</DialogTitle>
<DialogDescription>
Im gemeinsamen Modus sehen <strong>alle Nutzer dieses Mandanten</strong> über IMAP
alle archivierten Mails unabhängig von der ursprünglichen Empfängeradresse.
Diese Einstellung kann jederzeit zurückgestellt werden.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 py-2">
<Checkbox
id="confirm-shared"
checked={confirmed}
onCheckedChange={(v) => setConfirmed(v === true)}
/>
<Label htmlFor="confirm-shared">
Ich habe verstanden, dass alle Nutzer dieses Mandanten alle Mails sehen werden.
</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => setSharedDialogOpen(false)}>
Abbrechen
</Button>
<Button
variant="destructive"
disabled={!confirmed || saving}
onClick={handleConfirmShared}
>
{saving ? "Aktiviere..." : "Gemeinsames Archiv aktivieren"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}