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
+1 -1
View File
@@ -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)
+13 -13
View File
@@ -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
}
+61
View File
@@ -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})
}
+4
View File
@@ -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)))
+60 -39
View File
@@ -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++
}
+88
View File
@@ -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.
+22
View File
@@ -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
}
+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>
);
}