Files
nexarch/mail/internal/mailboxconfig/store.go
sysops 1825387603 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.
2026-09-02 23:47:32 +02:00

259 lines
8.9 KiB
Go

// 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(&quotaBytes)
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
}