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>
This commit is contained in:
sysops
2026-08-31 00:12:43 +02:00
co-authored by Claude Sonnet 5
parent cb5a9da701
commit 704b64fe27
5 changed files with 315 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
# ARC-03 Prüfprotokoll: Dublettenerkennung E-Mail
Voraussetzung ARC-01 (Mail, Fertig).
## Umsetzung
- `mail/internal/dedup/hash.go``HashAndBuffer(plaintext io.Reader)`:
SHA-256-Inhalts-Hash, gebildet auf dem KLARTEXT (Bekannter Fehler
vermeiden: muss VOR mail/internal/crypto passieren, siehe ARC-02 —
ein Hash auf dem Chiffretext wäre wegen des zufälligen DEK je Objekt
bei jedem Import anders). Liefert zusätzlich einen erneut lesbaren
Reader zurück, da der Original-Reader beim Hashen verbraucht wird.
- `mail/internal/dedup/store.go``Store.Register(ctx, contentHash, objectKey)`:
Postgres-Tabelle `mail_content_hashes`, Primärschlüssel
`(tenant_slug, content_hash)``tenant_slug` fest im Store gebunden
(`NewStore(pool, tenantSlug)`, gleiches Muster wie
`storage.Service`/`encstorage.Service`), nicht nur Konvention.
`ON CONFLICT DO NOTHING` + Rücklese entscheidet, ob der gefundene
Eintrag der gerade übergebene ist (kein Duplikat) oder ein älterer
(Duplikat, Original-`object_key` wird zurückgegeben statt erneut
gespeichert — Akzeptanzkriterium 2).
- Kein Umbau: `mail/internal/storage`/`mail/internal/crypto`/
`mail/internal/encstorage` unverändert (`git diff --stat` bleibt für
alle drei leer). `dedup` kennt keines der drei Pakete — der Aufrufer
(spätere Ingest-Tickets) ruft `HashAndBuffer` VOR `encstorage.Put`
auf.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: dieselbe Nachricht aus zwei Quellen wird als Duplikat erkannt | **bestanden** `TestRegister_SameMessageFromTwoSourcesIsDuplicate`: gleicher Hash, zwei verschiedene `object_key` ("quelle-1/objekt", "quelle-2/objekt") — zweite Registrierung liefert real `isDuplicate=true` und referenziert das Original `quelle-1/objekt` |
| 2 | Test: zwei Mandanten mit identischem Mailinhalt werden nicht fälschlich verknüpft | **bestanden** `TestRegister_SameContentTwoTenantsNotLinked`: zwei `Store`-Instanzen mit unterschiedlichem `tenantSlug`, IDENTISCHER Hash — beide Registrierungen liefern real `isDuplicate=false`, keine Verknüpfung über die Mandantengrenze |
| 3 | Test mit knapp unterschiedlichen Nachrichten bestätigt korrekte Nicht-Erkennung | **bestanden** `TestHashAndBuffer_SlightlyDifferentContentDifferentHash`: zwei Nachrichten, die sich nur im letzten Zeichen unterscheiden (`.` vs `,`) — real unterschiedlicher SHA-256-Hash |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/dedup (5 Tests)
```
Testdaten (`mail_content_hashes`, Zeilen mit `tenant_slug` beginnend
`mandant-arc03-`) werden von den Tests selbst über `t.Cleanup`
entfernt.
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt. Entsperrt SRC-01, SRC-02, SRC-07.
+35
View File
@@ -0,0 +1,35 @@
// Package dedup implementiert ARC-03: Dublettenerkennung für
// archivierte E-Mails über einen Inhalts-Hash. Kombiniert bewusst NICHT
// mit mail/internal/storage oder mail/internal/crypto — dieses Paket
// kennt beide nicht, der Aufrufer (spätere Ingest-Tickets) ruft es VOR
// mail/internal/crypto auf.
package dedup
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
)
// HashAndBuffer berechnet den SHA-256-Inhalts-Hash von plaintext.
//
// Bekannter Fehler vermeiden (siehe ARC-03-Ticket): der Hash MUSS auf
// dem Klartext berechnet werden, BEVOR mail/internal/crypto verschlüsselt
// — ein Hash auf dem Chiffretext wäre bei jedem Import anders (neuer
// DEK je Objekt, siehe ARC-02) und Dublettenerkennung würde vollständig
// versagen. Reihenfolge: Mail/Anhang empfangen -> HashAndBuffer (dieses
// Paket) -> verschlüsseln (ARC-02) -> ablegen.
//
// plaintext wird beim Hashen vollständig verbraucht — HashAndBuffer
// liefert deshalb einen erneut lesbaren Reader mit demselben Inhalt für
// den nachfolgenden Verschlüsselungsschritt zurück.
func HashAndBuffer(plaintext io.Reader) (contentHash string, buffered io.Reader, err error) {
var buf bytes.Buffer
hasher := sha256.New()
if _, err := io.Copy(hasher, io.TeeReader(plaintext, &buf)); err != nil {
return "", nil, fmt.Errorf("dedup: klartext hashen: %w", err)
}
return hex.EncodeToString(hasher.Sum(nil)), &buf, nil
}
+58
View File
@@ -0,0 +1,58 @@
package dedup
import (
"io"
"strings"
"testing"
)
func TestHashAndBuffer_SameContentSameHash(t *testing.T) {
h1, buf1, err := HashAndBuffer(strings.NewReader("identischer inhalt"))
if err != nil {
t.Fatal(err)
}
h2, buf2, err := HashAndBuffer(strings.NewReader("identischer inhalt"))
if err != nil {
t.Fatal(err)
}
if h1 != h2 {
t.Fatalf("erwartet identischen hash für identischen inhalt, habe %q vs %q", h1, h2)
}
got1, _ := io.ReadAll(buf1)
got2, _ := io.ReadAll(buf2)
if string(got1) != "identischer inhalt" || string(got2) != "identischer inhalt" {
t.Fatal("buffered reader liefert nicht denselben inhalt zurück wie der ursprüngliche klartext")
}
}
func TestHashAndBuffer_DifferentContentDifferentHash(t *testing.T) {
h1, _, err := HashAndBuffer(strings.NewReader("nachricht a"))
if err != nil {
t.Fatal(err)
}
h2, _, err := HashAndBuffer(strings.NewReader("nachricht b"))
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("unterschiedlicher inhalt hätte unterschiedlichen hash liefern müssen")
}
}
// TestHashAndBuffer_SlightlyDifferentContentDifferentHash ist die
// geforderte Pflichtprüfung 3: knapp unterschiedliche Nachrichten
// werden korrekt NICHT als Duplikat erkannt.
func TestHashAndBuffer_SlightlyDifferentContentDifferentHash(t *testing.T) {
h1, _, err := HashAndBuffer(strings.NewReader("Betreff: Test\r\n\r\nInhalt der Nachricht."))
if err != nil {
t.Fatal(err)
}
h2, _, err := HashAndBuffer(strings.NewReader("Betreff: Test\r\n\r\nInhalt der Nachricht,"))
if err != nil {
t.Fatal(err)
}
if h1 == h2 {
t.Fatal("ein einziges abweichendes zeichen hätte den hash ändern müssen")
}
}
+75
View File
@@ -0,0 +1,75 @@
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
}
@@ -0,0 +1,94 @@
// Integrationstest (ARC-03): echte Postgres-Instanz, folgt derselben
// Testhost-Konvention wie mail/internal/example (QA-01) — TEST_TENANT_DSN.
package dedup
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStore(t *testing.T, tenantSlug string) *Store {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
store := NewStore(pool, tenantSlug)
if err := store.EnsureSchema(ctx); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_content_hashes WHERE tenant_slug = $1`, tenantSlug)
})
return store
}
// TestRegister_SameMessageFromTwoSourcesIsDuplicate ist die geforderte
// Pflichtprüfung 1: dieselbe Nachricht aus zwei Quellen wird als
// Duplikat erkannt.
func TestRegister_SameMessageFromTwoSourcesIsDuplicate(t *testing.T) {
store := setupStore(t, "mandant-arc03-a")
ctx := context.Background()
hash := "fixierter-inhalts-hash-fuer-test-1"
isDup, _, err := store.Register(ctx, hash, "quelle-1/objekt")
if err != nil {
t.Fatalf("erste registrierung: %v", err)
}
if isDup {
t.Fatal("erste registrierung eines hashes darf kein duplikat sein")
}
isDup, existing, err := store.Register(ctx, hash, "quelle-2/objekt")
if err != nil {
t.Fatalf("zweite registrierung: %v", err)
}
if !isDup {
t.Fatal("erwartet: dieselbe nachricht aus zweiter quelle wird als duplikat erkannt")
}
if existing != "quelle-1/objekt" {
t.Fatalf("erwartet referenz auf das original quelle-1/objekt, habe %q", existing)
}
}
// TestRegister_SameContentTwoTenantsNotLinked ist die geforderte
// Pflichtprüfung 2: zwei Mandanten mit identischem Mailinhalt werden
// nicht fälschlich verknüpft.
func TestRegister_SameContentTwoTenantsNotLinked(t *testing.T) {
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
storeA := setupStore(t, "mandant-arc03-x")
storeB := setupStore(t, "mandant-arc03-y")
ctx := context.Background()
hash := "identischer-inhalt-ueber-zwei-mandanten-hinweg"
isDupA, _, err := storeA.Register(ctx, hash, "mandant-x/objekt")
if err != nil {
t.Fatalf("mandant a: %v", err)
}
if isDupA {
t.Fatal("erste registrierung bei mandant a darf kein duplikat sein")
}
isDupB, existingB, err := storeB.Register(ctx, hash, "mandant-y/objekt")
if err != nil {
t.Fatalf("mandant b: %v", err)
}
if isDupB {
t.Fatalf("mandant b wurde fälschlich mit mandant a verknüpft, existing=%q", existingB)
}
}