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:
@@ -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++
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user