Files
nexarch/mail/internal/dedup/store.go
T
sysopsandClaude Sonnet 5 704b64fe27 ARC-03: dublettenerkennung-e-mail
- mail/internal/dedup/hash.go: HashAndBuffer, SHA-256 auf Klartext VOR
  Verschluesselung (ARC-02), liefert erneut lesbaren Reader zurueck.
- mail/internal/dedup/store.go: Store (Postgres, tenant_slug fest im
  Primaerschluessel gebunden), Register: Duplikat referenziert Original
  statt redundant zu speichern.
- Alle 3 Pflichtpruefungen real bestanden (siehe
  mail/docs/ARC-03-PRUEFPROTOKOLL.md): Duplikat aus zwei Quellen
  erkannt, zwei Mandanten mit identischem Inhalt nicht verknuepft,
  knapp unterschiedliche Nachricht korrekt nicht erkannt.
- Kein Umbau: internal/storage, internal/crypto, internal/encstorage
  unveraendert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 00:12:43 +02:00

76 lines
2.5 KiB
Go

package dedup
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store verwaltet bekannte Inhalts-Hashes je Mandant. tenant_slug ist
// fester Bestandteil des Primärschlüssels (Akzeptanzkriterium 3:
// mandantenübergreifend korrekt getrennt) — auch wenn Store einen mit
// anderen Mandanten geteilten Pool erhält, kann ein Hash-Treffer nie
// über Mandantengrenzen hinweg entstehen.
type Store struct {
pool *pgxpool.Pool
tenantSlug string
}
func NewStore(pool *pgxpool.Pool, tenantSlug string) *Store {
return &Store{pool: pool, tenantSlug: tenantSlug}
}
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
// gleiches Muster wie mail/internal/example (kein zentraler
// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden).
func (s *Store) EnsureSchema(ctx context.Context) error {
if _, err := s.pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS mail_content_hashes (
tenant_slug TEXT NOT NULL,
content_hash TEXT NOT NULL,
object_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, content_hash)
)
`); err != nil {
return fmt.Errorf("dedup: schema anlegen: %w", err)
}
return nil
}
// Register trägt contentHash für den Mandanten als neu bekannt ein
// (Akzeptanzkriterium 1) und referenziert das Original, statt es
// redundant zu speichern (Akzeptanzkriterium 2): existiert derselbe
// Hash für DIESEN Mandanten bereits mit einem ANDEREN object_key,
// liefert Register isDuplicate=true und den object_key des Originals —
// der Aufrufer legt den neuen Inhalt dann NICHT ab.
func (s *Store) Register(ctx context.Context, contentHash, objectKey string) (isDuplicate bool, existingKey string, err error) {
if _, err := s.pool.Exec(ctx, `
INSERT INTO mail_content_hashes (tenant_slug, content_hash, object_key)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_slug, content_hash) DO NOTHING
`, s.tenantSlug, contentHash, objectKey); err != nil {
return false, "", fmt.Errorf("dedup: hash eintragen: %w", err)
}
var storedKey string
err = s.pool.QueryRow(ctx, `
SELECT object_key FROM mail_content_hashes
WHERE tenant_slug = $1 AND content_hash = $2
`, s.tenantSlug, contentHash).Scan(&storedKey)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, "", fmt.Errorf("dedup: gerade eingetragenen hash nicht wiedergefunden")
}
return false, "", fmt.Errorf("dedup: eintrag lesen: %w", err)
}
if storedKey != objectKey {
return true, storedKey, nil
}
return false, "", nil
}