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:
sysops
2026-09-02 23:47:32 +02:00
parent c344fa938b
commit 1825387603
12 changed files with 762 additions and 5 deletions
+69
View File
@@ -0,0 +1,69 @@
// Package quota implementiert ARC-09: konfigurierbares Speicherlimit
// je einzelnem Postfach — eine ANDERE Ebene als die tenant-weite Quota
// aus Core LIC-05 (bekannter Fehler zu vermeiden: beide Ebenen müssen
// unabhängig durchgesetzt werden, ein Tenant kann insgesamt genug
// Kontingent haben, während ein einzelnes Postfach überläuft).
package quota
import "context"
// LimitProvider liefert das konfigurierte Speicherlimit eines
// Postfachs (Akzeptanzkriterium 1). configured=false bedeutet: kein
// Limit gesetzt, also unbegrenzt — erfüllt von
// mail/internal/mailboxconfig.Store.LimitBytes.
type LimitProvider interface {
LimitBytes(ctx context.Context, tenantSlug, mailbox string) (limitBytes int64, configured bool, err error)
}
// UsageProvider liefert den TATSÄCHLICHEN, real gemessenen
// Speicherverbrauch eines Postfachs — erfüllt von
// mail/internal/storage.UsageCounter.
type UsageProvider interface {
UsageBytes(ctx context.Context, tenantSlug, mailbox string) (usedBytes int64, err error)
}
// Result ist das Ergebnis einer Quota-Prüfung.
type Result struct {
Allowed bool
UsedBytes int64
LimitBytes int64
}
// Checker verknüpft konfiguriertes Limit und realen Verbrauch.
type Checker struct {
limits LimitProvider
usage UsageProvider
}
func NewChecker(limits LimitProvider, usage UsageProvider) *Checker {
return &Checker{limits: limits, usage: usage}
}
// Check prüft, ob mailbox innerhalb seines konfigurierten Limits ist.
// Kein konfiguriertes Limit bedeutet immer "erlaubt" — die
// Tenant-weite Quota (Core LIC-05) läuft unabhängig davon weiter.
func (c *Checker) Check(ctx context.Context, tenantSlug, mailbox string) (Result, error) {
limit, configured, err := c.limits.LimitBytes(ctx, tenantSlug, mailbox)
if err != nil {
return Result{}, err
}
if !configured {
return Result{Allowed: true}, nil
}
used, err := c.usage.UsageBytes(ctx, tenantSlug, mailbox)
if err != nil {
return Result{}, err
}
return Result{Allowed: used < limit, UsedBytes: used, LimitBytes: limit}, nil
}
// Allowed ist die schmale Form von Check für Aufrufer, die nur die
// Ja/Nein-Entscheidung brauchen (z. B. mail/internal/smtp.QuotaChecker,
// Akzeptanzkriterium 2).
func (c *Checker) Allowed(ctx context.Context, tenantSlug, mailbox string) (bool, error) {
result, err := c.Check(ctx, tenantSlug, mailbox)
if err != nil {
return false, err
}
return result.Allowed, nil
}
+56
View File
@@ -0,0 +1,56 @@
package quota
import (
"context"
"testing"
)
type fakeLimits struct {
limitBytes int64
configured bool
}
func (f fakeLimits) LimitBytes(context.Context, string, string) (int64, bool, error) {
return f.limitBytes, f.configured, nil
}
type fakeUsage struct {
usedBytes int64
}
func (f fakeUsage) UsageBytes(context.Context, string, string) (int64, error) {
return f.usedBytes, nil
}
func TestCheck_UnconfiguredLimitAlwaysAllowed(t *testing.T) {
c := NewChecker(fakeLimits{configured: false}, fakeUsage{usedBytes: 1_000_000_000})
result, err := c.Check(context.Background(), "mandant-a", "postfach-x")
if err != nil {
t.Fatalf("check: %v", err)
}
if !result.Allowed {
t.Fatalf("erwartete erlaubt ohne konfiguriertes limit, habe: %+v", result)
}
}
func TestCheck_UsageAtOrAboveLimitRejected(t *testing.T) {
c := NewChecker(fakeLimits{limitBytes: 1000, configured: true}, fakeUsage{usedBytes: 1000})
result, err := c.Check(context.Background(), "mandant-a", "postfach-x")
if err != nil {
t.Fatalf("check: %v", err)
}
if result.Allowed {
t.Fatalf("erwartete ablehnung bei verbrauch == limit, habe: %+v", result)
}
}
func TestCheck_UsageBelowLimitAllowed(t *testing.T) {
c := NewChecker(fakeLimits{limitBytes: 1000, configured: true}, fakeUsage{usedBytes: 999})
result, err := c.Check(context.Background(), "mandant-a", "postfach-x")
if err != nil {
t.Fatalf("check: %v", err)
}
if !result.Allowed {
t.Fatalf("erwartete erlaubt bei verbrauch unter limit, habe: %+v", result)
}
}