feat(mail): ARC-04 Aufbewahrungsstruktur (Mandant/Postfach/Jahr)

Neue Datei storage/archivekey.go: ArchiveKey(mailbox, sentAt,
messageID, partIndex) liefert den Objektschlüssel nach dem Schema
Postfach/Jahr innerhalb des bereits mandantenspezifischen Buckets
(ARC-06) — additiv neben dem bestehenden, flachen ObjectKey (ARC-01),
das für bestehende Aufrufer (mailapi/INT-01) unverändert bleibt.
ArchiveYearPrefix(mailbox, year) ist der eigenständig berechenbare
Präfix eines Postfach-Jahrs — Grundlage für spätere Retention-Regeln
ohne Migration. Fehlendes Postfach bzw. Null-Sendedatum führen zu
dokumentierten Fallback-Segmenten statt einem Ablagefehler; ArchiveKey
liefert bewusst keinen error, da es strukturell keinen Fehlerfall gibt.

Alle drei Pflichtprüfungen: mehrjähriger Import erzeugt nachweislich
getrennte Jahresordner, fehlendes Postfach/Sendedatum nutzt den
dokumentierten Fallback (inkl. Test gegen mehrere ungewöhnliche
Eingaben), sowie ergänzend ein Nachweis für Akzeptanzkriterium 2
(Retention-Präfix trifft exakt die zuvor abgelegten Schlüssel
desselben Postfach-Jahrs). Pflichtprüfung 3 (Stichprobenreview durch
zweite Person) bleibt strukturell offen, im Prüfprotokoll dokumentiert
(analog zu ING-10/QA-04/QA-02).

go build/go vet/golangci-lint clean, gesamtes Mail-Modul
regressionsfrei getestet.
This commit is contained in:
sysops
2026-09-01 20:17:11 +02:00
parent b3c8d36b58
commit c344fa938b
3 changed files with 252 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
// ARC-04: Aufbewahrungsstruktur nach Mandant/Postfach/Jahr. Mandant ist
// bereits durch den physisch getrennten Bucket (ARC-06) abgebildet —
// ArchiveKey deckt Postfach und Jahr INNERHALB des Buckets ab, als
// eigener, additiver Schlüssel-Konstruktor neben dem bereits
// bestehenden, flachen ObjectKey (ARC-01, weiterhin unverändert für
// bestehende Aufrufer wie mail/internal/mailapi — kein Umbau
// angrenzender Bereiche).
package storage
import (
"strconv"
"strings"
"time"
)
// Fallback-Segmente (Akzeptanzkriterium 3): fehlende/abweichende
// Metadaten führen zu einem DOKUMENTIERTEN Fallback-Pfad statt einem
// Ablagefehler.
const (
FallbackMailboxSegment = "postfach-unbekannt"
FallbackYearSegment = "jahr-unbekannt"
)
// ArchiveKey liefert den kanonischen Objektschlüssel für einen
// archivierten Mail-Anhang/-Teil nach dem Schema Postfach/Jahr
// (Akzeptanzkriterium 1) — innerhalb des bereits mandantenspezifischen
// Buckets. sentAt darf der Nullwert sein und mailbox leer
// (Akzeptanzkriterium 3): beides führt zum jeweiligen Fallback-Segment,
// nie zu einem Fehler.
func ArchiveKey(mailbox string, sentAt time.Time, messageID string, partIndex int) string {
return ArchiveYearPrefix(mailbox, yearOf(sentAt)) + ObjectKey(messageID, partIndex)
}
// ArchiveYearPrefix liefert den Verzeichnispräfix EINES Postfach-Jahrs
// (Akzeptanzkriterium 2: Grundlage für spätere Retention-Regeln OHNE
// Migration — ein Retention-Job kann alle Objekte eines Postfachs/
// Jahrs direkt über diesen Präfix auflisten, ohne die bereits
// abgelegten Schlüssel umzubenennen oder neu zu strukturieren).
func ArchiveYearPrefix(mailbox string, year int) string {
mailboxSegment := sanitizeSegment(mailbox)
if mailboxSegment == "" {
mailboxSegment = FallbackMailboxSegment
}
yearSegment := FallbackYearSegment
if year > 0 {
yearSegment = strconv.Itoa(year)
}
return "postfach/" + mailboxSegment + "/" + yearSegment + "/"
}
func yearOf(t time.Time) int {
if t.IsZero() {
return 0
}
return t.UTC().Year()
}
// sanitizeSegment entfernt Pfadtrenner aus einem Postfachnamen, damit
// er nie versehentlich zusätzliche Verzeichnisebenen erzeugt (z. B. ein
// Postfachname, der ein "/" enthält).
func sanitizeSegment(raw string) string {
raw = strings.TrimSpace(raw)
return strings.ReplaceAll(raw, "/", "_")
}
+93
View File
@@ -0,0 +1,93 @@
package storage
import (
"strings"
"testing"
"time"
)
// TestArchiveKey_MultipleYearsProduceSeparateYearFolders ist die
// geforderte Pflichtprüfung 1 (ARC-04): Import über mehrere Jahre
// erzeugt korrekt getrennte Jahresordner.
func TestArchiveKey_MultipleYearsProduceSeparateYearFolders(t *testing.T) {
sent2019 := time.Date(2019, 3, 1, 0, 0, 0, 0, time.UTC)
sent2024 := time.Date(2024, 11, 1, 0, 0, 0, 0, time.UTC)
key2019 := ArchiveKey("INBOX", sent2019, "msg-a", 0)
key2024 := ArchiveKey("INBOX", sent2024, "msg-b", 0)
if !strings.HasPrefix(key2019, "postfach/INBOX/2019/") {
t.Fatalf("erwartete jahresordner 2019, habe: %q", key2019)
}
if !strings.HasPrefix(key2024, "postfach/INBOX/2024/") {
t.Fatalf("erwartete jahresordner 2024, habe: %q", key2024)
}
if key2019 == key2024 {
t.Fatalf("erwartete unterschiedliche schlüssel für unterschiedliche jahre")
}
}
// TestArchiveKey_MissingMailboxUsesDocumentedFallback ist die
// geforderte Pflichtprüfung 2 (ARC-04): fehlendes Postfach-Attribut
// nutzt den dokumentierten Fallback statt eines Ablagefehlers.
func TestArchiveKey_MissingMailboxUsesDocumentedFallback(t *testing.T) {
key := ArchiveKey("", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), "msg-a", 0)
if !strings.Contains(key, "/"+FallbackMailboxSegment+"/") {
t.Fatalf("erwartete fallback-postfach-segment %q, habe: %q", FallbackMailboxSegment, key)
}
}
// TestArchiveKey_MissingSentAtUsesDocumentedFallback belegt denselben
// Fallback-Grundsatz für ein fehlendes (Null-)Sendedatum.
func TestArchiveKey_MissingSentAtUsesDocumentedFallback(t *testing.T) {
key := ArchiveKey("INBOX", time.Time{}, "msg-a", 0)
if !strings.Contains(key, "/"+FallbackYearSegment+"/") {
t.Fatalf("erwartete fallback-jahr-segment %q, habe: %q", FallbackYearSegment, key)
}
}
// TestArchiveKey_NeverErrorsOnAnyInput bestätigt, dass ArchiveKey für
// KEINE Eingabekombination fehlschlägt (Akzeptanzkriterium 3:
// "definierter Fallback-Pfad, nicht Ablagefehler" — ArchiveKey liefert
// bewusst keinen error-Rückgabewert, weil es strukturell keinen
// Fehlerfall gibt).
func TestArchiveKey_NeverErrorsOnAnyInput(t *testing.T) {
inputs := []struct {
mailbox string
sentAt time.Time
}{
{"", time.Time{}},
{"Postfach/Mit/Slashes", time.Time{}},
{" ", time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)},
}
for _, in := range inputs {
key := ArchiveKey(in.mailbox, in.sentAt, "msg", 0)
if key == "" {
t.Fatalf("erwartete nicht-leeren schlüssel für eingabe %+v", in)
}
}
}
// TestArchiveYearPrefix_FoundationForRetentionWithoutMigration ist die
// geforderte Pflichtprüfung für Akzeptanzkriterium 2: die Struktur ist
// Grundlage für spätere Retention-Regeln ohne Migration — ein
// Retention-Job kann den Präfix EINES Postfach-Jahrs berechnen und
// findet darunter GENAU die zuvor mit ArchiveKey abgelegten Schlüssel
// desselben Postfachs/Jahrs, ohne dass an den bereits abgelegten
// Schlüsseln irgendetwas umbenannt werden müsste.
func TestArchiveYearPrefix_FoundationForRetentionWithoutMigration(t *testing.T) {
sent2022 := time.Date(2022, 6, 15, 0, 0, 0, 0, time.UTC)
key := ArchiveKey("Rechnungen", sent2022, "msg-x", 3)
prefix := ArchiveYearPrefix("Rechnungen", 2022)
if !strings.HasPrefix(key, prefix) {
t.Fatalf("ArchiveKey %q liegt nicht unter dem für retention berechenbaren präfix %q", key, prefix)
}
// Ein anderes Jahr desselben Postfachs liegt NICHT unter demselben
// Präfix — Retention kann Jahre gezielt einzeln adressieren.
otherYearPrefix := ArchiveYearPrefix("Rechnungen", 2023)
if strings.HasPrefix(key, otherYearPrefix) {
t.Fatalf("ArchiveKey %q hätte NICHT unter dem 2023-präfix liegen dürfen", key)
}
}