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:
@@ -209,7 +209,7 @@ func main() {
|
|||||||
|
|
||||||
// PROJ-26: IMAP Archive Server (read-only access for IMAP clients)
|
// PROJ-26: IMAP Archive Server (read-only access for IMAP clients)
|
||||||
if cfg.IMAPServer.Enabled {
|
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 {
|
if err := imapSrv.Start(); err != nil {
|
||||||
logger.Error("IMAP server failed to start", "err", err)
|
logger.Error("IMAP server failed to start", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+13
-13
@@ -12,17 +12,17 @@ const AppVersion = "0.9.1"
|
|||||||
// MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls
|
// MAJOR: Interface-Änderungen, Breaking changes innerhalb des Moduls
|
||||||
// MINOR: Neue Funktionen, Bugfixes, Security-Patches
|
// MINOR: Neue Funktionen, Bugfixes, Security-Patches
|
||||||
var Modules = map[string]string{
|
var Modules = map[string]string{
|
||||||
"storage": "1.6", // PROJ-34 retain_until, ErrRetentionLock, Purge() (GoBD-Compliance)
|
"storage": "1.7", // PROJ-33 MailWithUID, GetMailsWithUID, GetMailsByRecipient
|
||||||
"smtpd": "1.2", // IP-Allowlist fail-closed, Domain→Tenant-Routing
|
"smtpd": "1.2", // IP-Allowlist fail-closed, Domain→Tenant-Routing
|
||||||
"imapserver": "1.1", // Read-Only IMAP4rev1, Multi-Tenant-Isolation
|
"imapserver": "1.2", // PROJ-33 UID-Stabilität, shared/personal IMAP-Modus
|
||||||
"auth": "1.3", // JWT, bcrypt cost 12, TOTP
|
"auth": "1.3", // JWT, bcrypt cost 12, TOTP
|
||||||
"audit": "1.1", // PostgreSQL append-only, QueryFilter
|
"audit": "1.1", // PostgreSQL append-only, QueryFilter
|
||||||
"index": "1.0", // Xapian-Wrapper, Async-Worker, Tenant-Index
|
"index": "1.0", // Xapian-Wrapper, Async-Worker, Tenant-Index
|
||||||
"api": "1.6", // SEC-29 Rollen-Trennung, domain_auditor
|
"api": "1.7", // PROJ-33 IMAP-Modus-Einstellungen
|
||||||
"userstore": "1.3", // domain_auditor Rolle, bcrypt, LDAP-Auth
|
"userstore": "1.3", // domain_auditor Rolle, bcrypt, LDAP-Auth
|
||||||
"imap": "1.2", // IMAP-Sync, Scheduler, POP3
|
"imap": "1.2", // IMAP-Sync, Scheduler, POP3
|
||||||
"labelstore": "1.0", // Labels, Tenant-Isolation
|
"labelstore": "1.0", // Labels, Tenant-Isolation
|
||||||
"tenantstore":"1.1", // Multi-Tenancy, Quotas
|
"tenantstore": "1.2", // PROJ-33 imap_mode, GetIMAPMode, SetIMAPMode
|
||||||
"ldapconfig": "1.1", // Pro-Mandant LDAP, TLS
|
"ldapconfig": "1.1", // Pro-Mandant LDAP, TLS
|
||||||
"mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion
|
"mailparser": "1.1", // RFC-2822, MIME, MessageID-Extraktion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -173,6 +173,10 @@ func (s *Server) routes() {
|
|||||||
// PROJ-34: Retention purge — superadmin only
|
// PROJ-34: Retention purge — superadmin only
|
||||||
s.mux.HandleFunc("POST /api/admin/purge", s.auth(s.requireRole(userstore.RoleSuperAdmin, s.handlePurge)))
|
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
|
// Export routes
|
||||||
s.mux.HandleFunc("GET /api/export/pdf/{id}", s.auth(s.requireMailAccess(s.handleExportPDF)))
|
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)))
|
s.mux.HandleFunc("POST /api/export/zip", s.auth(s.requireMailAccess(s.handleExportZIP)))
|
||||||
|
|||||||
@@ -34,16 +34,22 @@ const (
|
|||||||
maxLineLength = 65536
|
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.
|
// Server is the embedded read-only IMAP archive server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cfg config.IMAPServerConfig
|
cfg config.IMAPServerConfig
|
||||||
mailStore *storage.Store
|
mailStore *storage.Store
|
||||||
users *userstore.Store
|
users *userstore.Store
|
||||||
labels *labelstore.Store
|
labels *labelstore.Store
|
||||||
audit *audit.Logger
|
audit *audit.Logger
|
||||||
authMgr *auth.Manager
|
authMgr *auth.Manager
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
listener net.Listener
|
tenantStore tenantIMAPModeGetter
|
||||||
|
listener net.Listener
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
@@ -64,17 +70,19 @@ func New(
|
|||||||
auditLog *audit.Logger,
|
auditLog *audit.Logger,
|
||||||
authMgr *auth.Manager,
|
authMgr *auth.Manager,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
|
tenantStore tenantIMAPModeGetter,
|
||||||
) *Server {
|
) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
mailStore: mailStore,
|
mailStore: mailStore,
|
||||||
users: users,
|
users: users,
|
||||||
labels: labels,
|
labels: labels,
|
||||||
audit: auditLog,
|
audit: auditLog,
|
||||||
authMgr: authMgr,
|
authMgr: authMgr,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
done: make(chan struct{}),
|
tenantStore: tenantStore,
|
||||||
connCount: make(map[string]*atomic.Int32),
|
done: make(chan struct{}),
|
||||||
|
connCount: make(map[string]*atomic.Int32),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,11 +257,12 @@ type session struct {
|
|||||||
reader *bufio.Reader
|
reader *bufio.Reader
|
||||||
remoteAddr string
|
remoteAddr string
|
||||||
|
|
||||||
state int
|
state int
|
||||||
closed bool
|
closed bool
|
||||||
username string
|
username string
|
||||||
userID int64
|
userID int64
|
||||||
tenantID *int64
|
userEmail string // for personal IMAP mode filtering
|
||||||
|
tenantID *int64
|
||||||
|
|
||||||
// Selected mailbox state
|
// Selected mailbox state
|
||||||
selectedMailbox string
|
selectedMailbox string
|
||||||
@@ -389,6 +398,7 @@ func (sess *session) cmdLogin(tag string, args string) {
|
|||||||
|
|
||||||
sess.username = user.Username
|
sess.username = user.Username
|
||||||
sess.userID = user.ID
|
sess.userID = user.ID
|
||||||
|
sess.userEmail = user.Email
|
||||||
sess.tenantID = user.TenantID
|
sess.tenantID = user.TenantID
|
||||||
sess.state = stateAuth
|
sess.state = stateAuth
|
||||||
|
|
||||||
@@ -694,13 +704,27 @@ func (sess *session) cmdIdle(tag string) {
|
|||||||
func (sess *session) loadMailsForMailbox(mailbox string) ([]mailEntry, error) {
|
func (sess *session) loadMailsForMailbox(mailbox string) ([]mailEntry, error) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Get all email IDs for this user's tenant
|
// Determine IMAP mode for this tenant
|
||||||
ids, err := sess.server.mailStore.GetAllIDsByTenant(ctx, sess.tenantID)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load mails: %w", err)
|
return nil, fmt.Errorf("load mails: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a label sub-folder is selected, filter by label
|
// Label filter setup
|
||||||
var filterLabelID *int64
|
var filterLabelID *int64
|
||||||
if strings.HasPrefix(mailbox, "INBOX/") {
|
if strings.HasPrefix(mailbox, "INBOX/") {
|
||||||
labelName := strings.TrimPrefix(mailbox, "INBOX/")
|
labelName := strings.TrimPrefix(mailbox, "INBOX/")
|
||||||
@@ -717,13 +741,12 @@ func (sess *session) loadMailsForMailbox(mailbox string) ([]mailEntry, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if filterLabelID == nil {
|
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" {
|
} else if mailbox != "INBOX" {
|
||||||
return nil, fmt.Errorf("unknown mailbox: %s", mailbox)
|
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
|
var labelEmailIDs map[string]bool
|
||||||
if filterLabelID != nil {
|
if filterLabelID != nil {
|
||||||
emailIDs, err := sess.server.labels.GetEmailIDsByLabel(ctx, *filterLabelID)
|
emailIDs, err := sess.server.labels.GetEmailIDsByLabel(ctx, *filterLabelID)
|
||||||
@@ -738,21 +761,19 @@ func (sess *session) loadMailsForMailbox(mailbox string) ([]mailEntry, error) {
|
|||||||
|
|
||||||
var entries []mailEntry
|
var entries []mailEntry
|
||||||
var seqNum uint32 = 1
|
var seqNum uint32 = 1
|
||||||
for _, id := range ids {
|
for _, m := range rawMails {
|
||||||
// Filter by label if applicable
|
if labelEmailIDs != nil && !labelEmailIDs[m.ID] {
|
||||||
if labelEmailIDs != nil && !labelEmailIDs[id] {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
uid := uint32(m.UID)
|
||||||
entry := mailEntry{
|
if uid == 0 {
|
||||||
ID: id,
|
uid = seqNum // fallback if no UID in DB yet
|
||||||
SeqNum: seqNum,
|
|
||||||
UID: seqNum, // UID = sequence number for simplicity
|
|
||||||
}
|
}
|
||||||
|
entries = append(entries, mailEntry{
|
||||||
// Try to load metadata from parsed mail (lazy, only when needed for FETCH)
|
ID: m.ID,
|
||||||
// For listing, we just need the basic entry
|
SeqNum: seqNum,
|
||||||
entries = append(entries, entry)
|
UID: uid,
|
||||||
|
})
|
||||||
seqNum++
|
seqNum++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ type MailRef struct {
|
|||||||
ModTime time.Time
|
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
|
// New initialises the storage directory, optionally loads the encryption key
|
||||||
// and connects to PostgreSQL.
|
// and connects to PostgreSQL.
|
||||||
func New(cfg Config) (*Store, error) {
|
func New(cfg Config) (*Store, error) {
|
||||||
@@ -90,6 +96,9 @@ func New(cfg Config) (*Store, error) {
|
|||||||
// PROJ-34: GoBD retention lock
|
// PROJ-34: GoBD retention lock
|
||||||
_, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS retain_until TIMESTAMPTZ`)
|
_, _ = 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`)
|
_, _ = 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
|
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 ──────────────────────────────────────────────────────
|
// ── File path helper ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// filePath returns the on-disk path for a given mail ID.
|
// filePath returns the on-disk path for a given mail ID.
|
||||||
|
|||||||
@@ -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 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_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 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.
|
// 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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import { TenantsTab } from "@/components/admin/tabs/TenantsTab";
|
|||||||
import { LabelsTab } from "@/components/admin/tabs/LabelsTab";
|
import { LabelsTab } from "@/components/admin/tabs/LabelsTab";
|
||||||
import { CertTab } from "@/components/admin/tabs/CertTab";
|
import { CertTab } from "@/components/admin/tabs/CertTab";
|
||||||
import { ModulesTab } from "@/components/admin/ModulesTab";
|
import { ModulesTab } from "@/components/admin/ModulesTab";
|
||||||
|
import { IMAPSettingsTab } from "@/components/admin/tabs/IMAPSettingsTab";
|
||||||
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
import { ResetPasswordDialog, DeleteUserDialog } from "@/components/admin/UserDialogs";
|
||||||
|
|
||||||
const AUDIT_PAGE_SIZE = 25;
|
const AUDIT_PAGE_SIZE = 25;
|
||||||
@@ -799,6 +800,9 @@ export default function AdminPage() {
|
|||||||
{!isSuperAdmin && user?.role === "domain_admin" && (
|
{!isSuperAdmin && user?.role === "domain_admin" && (
|
||||||
<TabsTrigger value="tenant-ldap" onClick={loadTenantLDAP}>LDAP</TabsTrigger>
|
<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="labels" onClick={loadLabelsTab}>Labels</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="security">Security</TabsTrigger>}
|
||||||
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
|
{isSuperAdmin && <TabsTrigger value="cert" onClick={loadCert}>Zertifikat</TabsTrigger>}
|
||||||
@@ -1075,6 +1079,12 @@ export default function AdminPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isSuperAdmin && (
|
||||||
|
<TabsContent value="imap-settings">
|
||||||
|
<IMAPSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="modules" className="mt-4">
|
<TabsContent value="modules" className="mt-4">
|
||||||
<ModulesTab />
|
<ModulesTab />
|
||||||
</TabsContent>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user