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