From 8d0f685fc96c3dff75816c4e20812c74d87db8de Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 31 Mar 2026 09:46:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(PROJ-33):=20IMAP=20UID-Stabilit=C3=A4t=20+?= =?UTF-8?q?=20Shared/Personal-Modus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/archivmail/main.go | 2 +- cmd/archivmail/version.go | 26 +-- internal/api/imap_settings_handlers.go | 61 +++++++ internal/api/server.go | 4 + internal/imapserver/server.go | 99 +++++++---- internal/storage/storage.go | 88 ++++++++++ internal/tenantstore/store.go | 22 +++ src/app/admin/page.tsx | 10 ++ src/components/admin/tabs/IMAPSettingsTab.tsx | 166 ++++++++++++++++++ 9 files changed, 425 insertions(+), 53 deletions(-) create mode 100644 internal/api/imap_settings_handlers.go create mode 100644 src/components/admin/tabs/IMAPSettingsTab.tsx diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index c4bcdf6..acdda4f 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -209,7 +209,7 @@ func main() { // PROJ-26: IMAP Archive Server (read-only access for IMAP clients) if cfg.IMAPServer.Enabled { - imapSrv := imapserver.New(cfg.IMAPServer, mailStore, users, labelSt, audlog, authMgr, logger) + imapSrv := imapserver.New(cfg.IMAPServer, mailStore, users, labelSt, audlog, authMgr, logger, tenantSt) if err := imapSrv.Start(); err != nil { logger.Error("IMAP server failed to start", "err", err) os.Exit(1) diff --git a/cmd/archivmail/version.go b/cmd/archivmail/version.go index c39ffe9..f6b7c61 100644 --- a/cmd/archivmail/version.go +++ b/cmd/archivmail/version.go @@ -12,17 +12,17 @@ const AppVersion = "0.9.1" // MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls // MINOR: Neue Funktionen, Bugfixes, Security-Patches var Modules = map[string]string{ - "storage": "1.6", // PROJ-34 retain_until, ErrRetentionLock, Purge() (GoBD-Compliance) - "smtpd": "1.2", // IP-Allowlist fail-closed, Domain→Tenant-Routing - "imapserver": "1.1", // Read-Only IMAP4rev1, Multi-Tenant-Isolation - "auth": "1.3", // JWT, bcrypt cost 12, TOTP - "audit": "1.1", // PostgreSQL append-only, QueryFilter - "index": "1.0", // Xapian-Wrapper, Async-Worker, Tenant-Index - "api": "1.6", // SEC-29 Rollen-Trennung, domain_auditor - "userstore": "1.3", // domain_auditor Rolle, bcrypt, LDAP-Auth - "imap": "1.2", // IMAP-Sync, Scheduler, POP3 - "labelstore": "1.0", // Labels, Tenant-Isolation - "tenantstore":"1.1", // Multi-Tenancy, Quotas - "ldapconfig": "1.1", // Pro-Mandant LDAP, TLS - "mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion + "storage": "1.7", // PROJ-33 MailWithUID, GetMailsWithUID, GetMailsByRecipient + "smtpd": "1.2", // IP-Allowlist fail-closed, Domain→Tenant-Routing + "imapserver": "1.2", // PROJ-33 UID-Stabilität, shared/personal IMAP-Modus + "auth": "1.3", // JWT, bcrypt cost 12, TOTP + "audit": "1.1", // PostgreSQL append-only, QueryFilter + "index": "1.0", // Xapian-Wrapper, Async-Worker, Tenant-Index + "api": "1.7", // PROJ-33 IMAP-Modus-Einstellungen + "userstore": "1.3", // domain_auditor Rolle, bcrypt, LDAP-Auth + "imap": "1.2", // IMAP-Sync, Scheduler, POP3 + "labelstore": "1.0", // Labels, Tenant-Isolation + "tenantstore": "1.2", // PROJ-33 imap_mode, GetIMAPMode, SetIMAPMode + "ldapconfig": "1.1", // Pro-Mandant LDAP, TLS + "mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion } diff --git a/internal/api/imap_settings_handlers.go b/internal/api/imap_settings_handlers.go new file mode 100644 index 0000000..6267847 --- /dev/null +++ b/internal/api/imap_settings_handlers.go @@ -0,0 +1,61 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/archivmail/internal/audit" +) + +// handleGetIMAPMode returns the current IMAP mode for the tenant. +// GET /api/admin/settings/imap-mode +func (s *Server) handleGetIMAPMode(w http.ResponseWriter, r *http.Request) { + tenantID := tenantFromCtx(r.Context()) + if tenantID == nil { + writeError(w, http.StatusBadRequest, "no tenant context") + return + } + mode, err := s.tenantStore.GetIMAPMode(r.Context(), *tenantID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"mode": mode}) +} + +// handleSetIMAPMode sets the IMAP mode for the tenant. +// PUT /api/admin/settings/imap-mode +// Body: { "mode": "shared"|"personal", "confirmed": true } +func (s *Server) handleSetIMAPMode(w http.ResponseWriter, r *http.Request) { + tenantID := tenantFromCtx(r.Context()) + if tenantID == nil { + writeError(w, http.StatusBadRequest, "no tenant context") + return + } + var req struct { + Mode string `json:"mode"` + Confirmed bool `json:"confirmed"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + // Double opt-in for shared mode + if req.Mode == "shared" && !req.Confirmed { + writeError(w, http.StatusBadRequest, "confirmed must be true to enable shared mode") + return + } + if err := s.tenantStore.SetIMAPMode(r.Context(), *tenantID, req.Mode); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + sess := sessionFromCtx(r.Context()) + s.audlog.Log(audit.Entry{ + EventType: "imap_mode_changed", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Detail: "imap_mode set to " + req.Mode, + Success: true, + }) + writeJSON(w, http.StatusOK, map[string]string{"mode": req.Mode}) +} diff --git a/internal/api/server.go b/internal/api/server.go index a68d6c8..bec29b1 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -173,6 +173,10 @@ func (s *Server) routes() { // PROJ-34: Retention purge — superadmin only s.mux.HandleFunc("POST /api/admin/purge", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handlePurge))) + // PROJ-33: IMAP mode settings — domain_admin only + s.mux.HandleFunc("GET /api/admin/settings/imap-mode", s.authAdmin(s.handleGetIMAPMode)) + s.mux.HandleFunc("PUT /api/admin/settings/imap-mode", s.authAdmin(s.handleSetIMAPMode)) + // Export routes s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF))) s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP))) diff --git a/internal/imapserver/server.go b/internal/imapserver/server.go index f77817a..2540ccb 100644 --- a/internal/imapserver/server.go +++ b/internal/imapserver/server.go @@ -34,16 +34,22 @@ const ( maxLineLength = 65536 ) +// tenantIMAPModeGetter is satisfied by tenantstore.Store. +type tenantIMAPModeGetter interface { + GetIMAPMode(ctx context.Context, tenantID int64) (string, error) +} + // Server is the embedded read-only IMAP archive server. type Server struct { - cfg config.IMAPServerConfig - mailStore *storage.Store - users *userstore.Store - labels *labelstore.Store - audit *audit.Logger - authMgr *auth.Manager - logger *slog.Logger - listener net.Listener + cfg config.IMAPServerConfig + mailStore *storage.Store + users *userstore.Store + labels *labelstore.Store + audit *audit.Logger + authMgr *auth.Manager + logger *slog.Logger + tenantStore tenantIMAPModeGetter + listener net.Listener mu sync.Mutex running bool @@ -64,17 +70,19 @@ func New( auditLog *audit.Logger, authMgr *auth.Manager, logger *slog.Logger, + tenantStore tenantIMAPModeGetter, ) *Server { return &Server{ - cfg: cfg, - mailStore: mailStore, - users: users, - labels: labels, - audit: auditLog, - authMgr: authMgr, - logger: logger, - done: make(chan struct{}), - connCount: make(map[string]*atomic.Int32), + cfg: cfg, + mailStore: mailStore, + users: users, + labels: labels, + audit: auditLog, + authMgr: authMgr, + logger: logger, + tenantStore: tenantStore, + done: make(chan struct{}), + connCount: make(map[string]*atomic.Int32), } } @@ -249,11 +257,12 @@ type session struct { reader *bufio.Reader remoteAddr string - state int - closed bool - username string - userID int64 - tenantID *int64 + state int + closed bool + username string + userID int64 + userEmail string // for personal IMAP mode filtering + tenantID *int64 // Selected mailbox state selectedMailbox string @@ -389,6 +398,7 @@ func (sess *session) cmdLogin(tag string, args string) { sess.username = user.Username sess.userID = user.ID + sess.userEmail = user.Email sess.tenantID = user.TenantID sess.state = stateAuth @@ -694,13 +704,27 @@ func (sess *session) cmdIdle(tag string) { func (sess *session) loadMailsForMailbox(mailbox string) ([]mailEntry, error) { ctx := context.Background() - // Get all email IDs for this user's tenant - ids, err := sess.server.mailStore.GetAllIDsByTenant(ctx, sess.tenantID) + // Determine IMAP mode for this tenant + mode := "personal" + if sess.tenantID != nil && sess.server.tenantStore != nil { + if m, err := sess.server.tenantStore.GetIMAPMode(ctx, *sess.tenantID); err == nil { + mode = m + } + } + + // Load mails with stable UIDs depending on mode + var rawMails []storage.MailWithUID + var err error + if mode == "shared" { + rawMails, err = sess.server.mailStore.GetMailsWithUID(ctx, sess.tenantID) + } else { + rawMails, err = sess.server.mailStore.GetMailsByRecipient(ctx, sess.tenantID, sess.userEmail) + } if err != nil { return nil, fmt.Errorf("load mails: %w", err) } - // If a label sub-folder is selected, filter by label + // Label filter setup var filterLabelID *int64 if strings.HasPrefix(mailbox, "INBOX/") { labelName := strings.TrimPrefix(mailbox, "INBOX/") @@ -717,13 +741,12 @@ func (sess *session) loadMailsForMailbox(mailbox string) ([]mailEntry, error) { } } if filterLabelID == nil { - return nil, fmt.Errorf("label not found: %s", labelName) + return nil, fmt.Errorf("label not found: %s", mailbox) } } else if mailbox != "INBOX" { return nil, fmt.Errorf("unknown mailbox: %s", mailbox) } - // If filtering by label, get the email IDs that have this label var labelEmailIDs map[string]bool if filterLabelID != nil { emailIDs, err := sess.server.labels.GetEmailIDsByLabel(ctx, *filterLabelID) @@ -738,21 +761,19 @@ func (sess *session) loadMailsForMailbox(mailbox string) ([]mailEntry, error) { var entries []mailEntry var seqNum uint32 = 1 - for _, id := range ids { - // Filter by label if applicable - if labelEmailIDs != nil && !labelEmailIDs[id] { + for _, m := range rawMails { + if labelEmailIDs != nil && !labelEmailIDs[m.ID] { continue } - - entry := mailEntry{ - ID: id, - SeqNum: seqNum, - UID: seqNum, // UID = sequence number for simplicity + uid := uint32(m.UID) + if uid == 0 { + uid = seqNum // fallback if no UID in DB yet } - - // Try to load metadata from parsed mail (lazy, only when needed for FETCH) - // For listing, we just need the basic entry - entries = append(entries, entry) + entries = append(entries, mailEntry{ + ID: m.ID, + SeqNum: seqNum, + UID: uid, + }) seqNum++ } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index c32159c..1922cd9 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -55,6 +55,12 @@ type MailRef struct { ModTime time.Time } +// MailWithUID holds the ID and stable IMAP UID of a stored mail. +type MailWithUID struct { + ID string + UID int64 +} + // New initialises the storage directory, optionally loads the encryption key // and connects to PostgreSQL. func New(cfg Config) (*Store, error) { @@ -90,6 +96,9 @@ func New(cfg Config) (*Store, error) { // PROJ-34: GoBD retention lock _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ`) _, _ = s.db.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_emails_retain_until ON emails (retain_until) WHERE retain_until IS NOT NULL`) + // PROJ-33: Stable IMAP UIDs + _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS uid BIGSERIAL`) + _, _ = s.db.Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_emails_uid ON emails (uid)`) } return s, nil @@ -735,6 +744,85 @@ func (s *Store) WalkStore(ctx context.Context, fn func(id string) error) error { }) } +// ── IMAP UID queries ────────────────────────────────────────────────────── + +// GetMailsWithUID returns all email IDs with stable UIDs for a tenant, ordered by uid ASC. +// Used for shared IMAP mode. +func (s *Store) GetMailsWithUID(ctx context.Context, tenantID *int64) ([]MailWithUID, error) { + if s.db == nil { + ids, err := s.GetAllIDsByTenant(ctx, tenantID) + if err != nil { + return nil, err + } + out := make([]MailWithUID, len(ids)) + for i, id := range ids { + out[i] = MailWithUID{ID: id, UID: int64(i + 1)} + } + return out, nil + } + var rows pgx.Rows + var err error + if tenantID == nil { + rows, err = s.db.Query(ctx, + `SELECT id, COALESCE(uid, 0) FROM emails ORDER BY uid ASC NULLS LAST`) + } else { + rows, err = s.db.Query(ctx, ` + SELECT e.id, COALESCE(e.uid, 0) + FROM email_refs r + JOIN emails e ON e.id = r.email_id + WHERE r.tenant_id = $1 + ORDER BY e.uid ASC NULLS LAST`, *tenantID) + } + if err != nil { + return nil, fmt.Errorf("storage: get mails with uid: %w", err) + } + defer rows.Close() + var result []MailWithUID + for rows.Next() { + var m MailWithUID + if err := rows.Scan(&m.ID, &m.UID); err == nil { + result = append(result, m) + } + } + return result, rows.Err() +} + +// GetMailsByRecipient returns mails where mail_to contains the given email address. +// Used for personal IMAP mode filtering. +func (s *Store) GetMailsByRecipient(ctx context.Context, tenantID *int64, email string) ([]MailWithUID, error) { + if s.db == nil || email == "" { + return nil, nil + } + pattern := "%" + email + "%" + var rows pgx.Rows + var err error + if tenantID == nil { + rows, err = s.db.Query(ctx, + `SELECT id, COALESCE(uid, 0) FROM emails WHERE mail_to ILIKE $1 ORDER BY uid ASC NULLS LAST`, + pattern) + } else { + rows, err = s.db.Query(ctx, ` + SELECT e.id, COALESCE(e.uid, 0) + FROM email_refs r + JOIN emails e ON e.id = r.email_id + WHERE r.tenant_id = $1 + AND e.mail_to ILIKE $2 + ORDER BY e.uid ASC NULLS LAST`, *tenantID, pattern) + } + if err != nil { + return nil, fmt.Errorf("storage: get mails by recipient: %w", err) + } + defer rows.Close() + var result []MailWithUID + for rows.Next() { + var m MailWithUID + if err := rows.Scan(&m.ID, &m.UID); err == nil { + result = append(result, m) + } + } + return result, rows.Err() +} + // ── File path helper ────────────────────────────────────────────────────── // filePath returns the on-disk path for a given mail ID. diff --git a/internal/tenantstore/store.go b/internal/tenantstore/store.go index 35ad28e..77c5691 100644 --- a/internal/tenantstore/store.go +++ b/internal/tenantstore/store.go @@ -74,6 +74,7 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(i ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS tenant_id BIGINT REFERENCES tenants(id); ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_data BYTEA; ALTER TABLE tenants ADD COLUMN IF NOT EXISTS logo_content_type VARCHAR(100) NOT NULL DEFAULT ''; +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS imap_mode TEXT NOT NULL DEFAULT 'personal'; ` // New connects to PostgreSQL and initialises the tenant schema. @@ -326,3 +327,24 @@ func (s *Store) getDomain(ctx context.Context, id int64) (*TenantDomain, error) } return &d, nil } + +// ── IMAP Mode ───────────────────────────────────────────────────────────── + +// GetIMAPMode returns the imap_mode for a tenant ("personal" or "shared"). +func (s *Store) GetIMAPMode(ctx context.Context, tenantID int64) (string, error) { + var mode string + err := s.pool.QueryRow(ctx, `SELECT imap_mode FROM tenants WHERE id = $1`, tenantID).Scan(&mode) + if err != nil { + return "personal", nil // safe default + } + return mode, nil +} + +// SetIMAPMode updates the imap_mode for a tenant. Valid values: "personal", "shared". +func (s *Store) SetIMAPMode(ctx context.Context, tenantID int64, mode string) error { + if mode != "personal" && mode != "shared" { + return fmt.Errorf("tenantstore: invalid imap_mode %q", mode) + } + _, err := s.pool.Exec(ctx, `UPDATE tenants SET imap_mode = $1 WHERE id = $2`, mode, tenantID) + return err +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 2b38bbd..494f81a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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" && ( LDAP )} + {!isSuperAdmin && user?.role === "domain_admin" && ( + IMAP + )} {isSuperAdmin && Labels} {isSuperAdmin && Security} {isSuperAdmin && Zertifikat} @@ -1075,6 +1079,12 @@ export default function AdminPage() { )} + {!isSuperAdmin && ( + + + + )} + diff --git a/src/components/admin/tabs/IMAPSettingsTab.tsx b/src/components/admin/tabs/IMAPSettingsTab.tsx new file mode 100644 index 0000000..20c8ce0 --- /dev/null +++ b/src/components/admin/tabs/IMAPSettingsTab.tsx @@ -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 { + 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 { + 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("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 ( +
+ + + IMAP-Zugriffsmodus + + + {loading ? ( +

Lädt...

+ ) : ( + <> +
+ Aktueller Modus: + + {mode === "shared" ? "Gemeinsames Archiv" : "Persönlicher Posteingang"} + +
+

+ {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."} +

+ {error &&

{error}

} +
+ {mode !== "personal" && ( + + )} + {mode !== "shared" && ( + + )} +
+ + )} +
+
+ + + + + Gemeinsames Archiv aktivieren? + + Im gemeinsamen Modus sehen alle Nutzer dieses Mandanten über IMAP + alle archivierten Mails — unabhängig von der ursprünglichen Empfängeradresse. + Diese Einstellung kann jederzeit zurückgestellt werden. + + +
+ setConfirmed(v === true)} + /> + +
+ {error &&

{error}

} + + + + +
+
+
+ ); +}