// Package mailboxconfig implementiert IMP-07: Verwaltung mehrerer // Postfächer je Mandant (Anlage, getrennte Abrufkonfiguration je // Postfach). Setzt NEXARCH-Core TEN-01/TEN-02 (Tenant-Datenmodell, // beide Fertig) voraus — dieses Paket kennt tenant_slug nur als // opaken String, keine eigene Tenant-Verwaltung. // // Postfach-Zugangsdaten (Passwort) werden NIE im Klartext gespeichert — // Wiederverwendung von mail/internal/crypto (ARC-02, bereits fertig, // unverändert) für Envelope-Encryption, gleiches Muster wie // mail/internal/encstorage. package mailboxconfig import ( "bytes" "context" _ "embed" "errors" "fmt" "io" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/mail/internal/crypto" ) //go:embed migrations/0001_mail_mailboxes.sql var schemaMigration string //go:embed migrations/0002_mail_mailboxes_quota.sql var quotaMigration string // ErrNotFound wird geliefert, wenn kein Postfach mit den angegebenen // Bezugsdaten existiert. var ErrNotFound = errors.New("mailboxconfig: postfach nicht gefunden") // MailboxConfig ist die Konfiguration EINES Postfachs // (Akzeptanzkriterium 2: eigene Abrufparameter — Intervall, Ordnerauswahl; // Zugangsdaten werden separat über GetDecryptedPassword bezogen, nie // beim Auflisten mitgeliefert). type MailboxConfig struct { ID int64 TenantSlug string Name string IMAPHost string IMAPPort int IMAPUsername string FolderSelection []string IntervalSeconds int } const defaultIntervalSeconds = 300 // Store verwaltet Postfachkonfigurationen je Mandant in Postgres. type Store struct { pool *pgxpool.Pool crypto *crypto.Service } func NewStore(pool *pgxpool.Pool, cryptoSvc *crypto.Service) *Store { return &Store{pool: pool, crypto: cryptoSvc} } // EnsureSchema legt die Tabelle an, falls sie noch nicht existiert, und // zieht die quota_bytes-Spalte nach (ARC-09). func (s *Store) EnsureSchema(ctx context.Context) error { if _, err := s.pool.Exec(ctx, schemaMigration); err != nil { return fmt.Errorf("mailboxconfig: schema anlegen: %w", err) } if _, err := s.pool.Exec(ctx, quotaMigration); err != nil { return fmt.Errorf("mailboxconfig: quota-spalte anlegen: %w", err) } return nil } // CreateInput sind die für die Anlage nötigen Angaben. type CreateInput struct { Name string IMAPHost string IMAPPort int IMAPUsername string Password string FolderSelection []string IntervalSeconds int } // Create legt ein neues Postfach für tenantSlug an (Akzeptanzkriterium 1: // ein Mandant kann mehrere Postfächer unabhängig konfigurieren — kein // Limit, keine gegenseitige Abhängigkeit zwischen Postfächern desselben // Mandanten). Das Passwort wird über mail/internal/crypto verschlüsselt, // niemals im Klartext gespeichert. func (s *Store) Create(ctx context.Context, tenantSlug string, in CreateInput) (int64, error) { if in.IntervalSeconds <= 0 { in.IntervalSeconds = defaultIntervalSeconds } if len(in.FolderSelection) == 0 { in.FolderSelection = []string{"INBOX"} } env, err := s.crypto.Seal(ctx, tenantSlug, strings.NewReader(in.Password)) if err != nil { return 0, fmt.Errorf("mailboxconfig: passwort verschlüsseln: %w", err) } ciphertext, err := io.ReadAll(env.Ciphertext) if err != nil { return 0, fmt.Errorf("mailboxconfig: chiffretext lesen: %w", err) } var id int64 err = s.pool.QueryRow(ctx, ` INSERT INTO mail_mailboxes (tenant_slug, name, imap_host, imap_port, imap_username, wrapped_password_dek, encrypted_password, folder_selection, interval_seconds) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id `, tenantSlug, in.Name, in.IMAPHost, in.IMAPPort, in.IMAPUsername, env.WrappedDEK, ciphertext, strings.Join(in.FolderSelection, ","), in.IntervalSeconds).Scan(&id) if err != nil { return 0, fmt.Errorf("mailboxconfig: postfach anlegen: %w", err) } return id, nil } // List liefert alle Postfächer eines Mandanten (Akzeptanzkriterium 3: // strikt nach tenant_slug gefiltert) — OHNE Zugangsdaten. func (s *Store) List(ctx context.Context, tenantSlug string) ([]MailboxConfig, error) { rows, err := s.pool.Query(ctx, ` SELECT id, name, imap_host, imap_port, imap_username, folder_selection, interval_seconds FROM mail_mailboxes WHERE tenant_slug = $1 ORDER BY name `, tenantSlug) if err != nil { return nil, fmt.Errorf("mailboxconfig: postfächer lesen: %w", err) } defer rows.Close() var configs []MailboxConfig for rows.Next() { var c MailboxConfig var folders string c.TenantSlug = tenantSlug if err := rows.Scan(&c.ID, &c.Name, &c.IMAPHost, &c.IMAPPort, &c.IMAPUsername, &folders, &c.IntervalSeconds); err != nil { return nil, fmt.Errorf("mailboxconfig: postfachzeile lesen: %w", err) } c.FolderSelection = strings.Split(folders, ",") configs = append(configs, c) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("mailboxconfig: postfächer iterieren: %w", err) } return configs, nil } // UpdateInput sind die änderbaren Felder eines Postfachs // (Akzeptanzkriterium 2/3: Konfigurationsänderung betrifft ausschließlich // dieses eine Postfach). type UpdateInput struct { IMAPHost string IMAPPort int FolderSelection []string IntervalSeconds int } // Update ändert die Abrufparameter EINES Postfachs, streng auf // tenantSlug+id beschränkt. func (s *Store) Update(ctx context.Context, tenantSlug string, id int64, in UpdateInput) error { tag, err := s.pool.Exec(ctx, ` UPDATE mail_mailboxes SET imap_host = $3, imap_port = $4, folder_selection = $5, interval_seconds = $6, updated_at = now() WHERE tenant_slug = $1 AND id = $2 `, tenantSlug, id, in.IMAPHost, in.IMAPPort, strings.Join(in.FolderSelection, ","), in.IntervalSeconds) if err != nil { return fmt.Errorf("mailboxconfig: postfach aktualisieren: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // Delete entfernt GENAU EIN Postfach, streng auf tenantSlug+id beschränkt // (Akzeptanzkriterium/Pflichtprüfung 2: andere Postfächer desselben // Mandanten bleiben unberührt). func (s *Store) Delete(ctx context.Context, tenantSlug string, id int64) error { tag, err := s.pool.Exec(ctx, `DELETE FROM mail_mailboxes WHERE tenant_slug = $1 AND id = $2`, tenantSlug, id) if err != nil { return fmt.Errorf("mailboxconfig: postfach löschen: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // SetQuotaBytes setzt das Speicherlimit EINES Postfachs, identifiziert // über seinen (innerhalb des Mandanten eindeutigen) Namen (ARC-09 // Akzeptanzkriterium 1: je Postfach konfigurierbar, unabhängig von der // Tenant-weiten Core-LIC-05-Quota). 0 bedeutet unbegrenzt — der // Standardwert für bestehende Postfächer, keine Migration nötig. func (s *Store) SetQuotaBytes(ctx context.Context, tenantSlug, name string, quotaBytes int64) error { tag, err := s.pool.Exec(ctx, ` UPDATE mail_mailboxes SET quota_bytes = $3, updated_at = now() WHERE tenant_slug = $1 AND name = $2 `, tenantSlug, name, quotaBytes) if err != nil { return fmt.Errorf("mailboxconfig: quota setzen: %w", err) } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // LimitBytes erfüllt quota.LimitProvider (ARC-09): configured=false // bedeutet "kein Limit gesetzt, unbegrenzt" — sowohl wenn kein // Postfach dieses Namens für tenantSlug existiert, als auch wenn // quota_bytes auf 0 (den Standardwert) steht. func (s *Store) LimitBytes(ctx context.Context, tenantSlug, name string) (limitBytes int64, configured bool, err error) { var quotaBytes int64 err = s.pool.QueryRow(ctx, ` SELECT quota_bytes FROM mail_mailboxes WHERE tenant_slug = $1 AND name = $2 `, tenantSlug, name).Scan("aBytes) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return 0, false, nil } return 0, false, fmt.Errorf("mailboxconfig: quota lesen: %w", err) } if quotaBytes <= 0 { return 0, false, nil } return quotaBytes, true, nil } // GetDecryptedPassword entschlüsselt das Postfach-Passwort — separater, // bewusster Aufruf statt Bestandteil von List/Get, damit Zugangsdaten // nicht beiläufig mitgeliefert werden. func (s *Store) GetDecryptedPassword(ctx context.Context, tenantSlug string, id int64) (string, error) { var wrappedDEK, ciphertext []byte err := s.pool.QueryRow(ctx, ` SELECT wrapped_password_dek, encrypted_password FROM mail_mailboxes WHERE tenant_slug = $1 AND id = $2 `, tenantSlug, id).Scan(&wrappedDEK, &ciphertext) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return "", ErrNotFound } return "", fmt.Errorf("mailboxconfig: postfach lesen: %w", err) } plaintextReader, err := s.crypto.Open(ctx, tenantSlug, wrappedDEK, bytes.NewReader(ciphertext)) if err != nil { return "", fmt.Errorf("mailboxconfig: passwort entschlüsseln: %w", err) } plaintext, err := io.ReadAll(plaintextReader) if err != nil { return "", fmt.Errorf("mailboxconfig: passwort lesen: %w", err) } return string(plaintext), nil }