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