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
+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
}