feat(mail): ARC-09 Postfach-Quota (unabhängig von Core LIC-05)
mailboxconfig (IMP-07) bekommt eine quota_bytes-Spalte statt einer eigenen Tabelle — ein Postfach ist bereits eindeutig über (tenant_slug, name) identifiziert. SetQuotaBytes/LimitBytes, 0 = unbegrenzt (Standardwert, keine Migration bestehender Postfächer nötig). LimitBytes erfüllt strukturell quota.LimitProvider. storage.ArchiveMailboxPrefix (ARC-04-Ergänzung, Präfix ALLER Jahre eines Postfachs) und storage.UsageCounter: realer Speicherverbrauch durch echtes S3-Listing im physisch getrennten Mandanten-Bucket (ARC-06) — kein separat gepflegter Zählerstand. Neues Paket mail/internal/quota: Checker verbindet LimitProvider und UsageProvider. Kein konfiguriertes Limit = immer erlaubt (Core-LIC-05- Quota läuft unabhängig weiter — beide Ebenen bewusst unabhängig durchgesetzt, bekannter Fehler vermieden). smtp.QuotaChecker (schmale Schnittstelle, keine Paketkopplung an quota) wird in handleRcptTo geprüft, VOR der Datenübertragung: 552 (RFC 5321 "exceeded storage allocation") bei Überschreitung, Session bleibt nutzbar. nil-Checker erhält bisheriges Verhalten unverändert. Alle drei Pflichtprüfungen mit echten Nachweisen: Quota-Überschreitung liefert 552, Session bleibt funktionsfähig; ein anderes Postfach desselben Tenants läuft währenddessen vollständig normal durch; vollständiger Ende-zu-Ende-Integrationstest gegen reale Postgres- und MinIO-Instanzen — 5000 echte Bytes abgelegt, real gemessen, Limit knapp darunter/darüber gesetzt, SMTP reagiert jeweils korrekt auf den tatsächlichen gemessenen Wert. Dabei einen echten Cleanup-Fehler gefunden und behoben (defer schloss den Pool vor dem zugehörigen t.Cleanup, verwaiste Testdaten blieben zurück). go build/go vet/golangci-lint clean, gesamtes Mail-Modul regressionsfrei getestet.
This commit is contained in:
@@ -28,6 +28,9 @@ import (
|
||||
//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")
|
||||
@@ -59,11 +62,15 @@ 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.
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -183,6 +190,46 @@ func (s *Store) Delete(ctx context.Context, tenantSlug string, id int64) error {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user