Verwaltung mehrerer Postfächer je Mandant: Anlage, getrennte Abrufkonfiguration pro Postfach. - store.go: Postgres-Store, beliebig viele unabhängige Postfächer je Mandant, eigene Abrufparameter (Intervall, Host/Port/Benutzername, Ordnerauswahl) je Postfach. Passwort nie im Klartext gespeichert — Wiederverwendung von mail/internal/crypto (ARC-02, unverändert) für Envelope-Encryption. List filtert strikt nach tenant_slug, Update/Delete streng auf tenant_slug+id beschränkt. Prüfungen (alle real durchgeführt, siehe mail/docs/IMP-07-PRUEFPROTOKOLL.md): 1. TestList_TwoTenantsWithMultipleMailboxesSeeOnlyOwn: zwei Mandanten sehen real ausschließlich eigene Postfächer. 2. TestDelete_DoesNotAffectSiblingMailboxes: Löschen real ohne Auswirkung auf Geschwister-Postfächer. 3. TestUpdate_ConfigChangeDoesNotAffectOtherMailboxes: Änderung real isoliert auf ein Postfach beschränkt. Kein Umbau: mail/internal/crypto unverändert wiederverwendet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
212 lines
7.1 KiB
Go
212 lines
7.1 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
|
|
|
|
// 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.
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|