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