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:
@@ -0,0 +1,95 @@
|
||||
# ARC-04 — Aufbewahrungsstruktur (Mandant/Postfach/Jahr): Prüfprotokoll
|
||||
|
||||
Datum: 2026-09-01
|
||||
Host: 192.168.1.131 (Build/Test/Lint), rsync + ssh
|
||||
Paket: `mail/internal/storage` (`archivekey.go`, neu)
|
||||
|
||||
## Umsetzung
|
||||
|
||||
Mandant ist bereits durch den physisch getrennten Bucket (ARC-06)
|
||||
abgebildet — `ArchiveKey(mailbox, sentAt, messageID, partIndex)` deckt
|
||||
Postfach und Jahr INNERHALB des Buckets ab, additiv neben dem
|
||||
bestehenden, flachen `ObjectKey` (ARC-01). `ObjectKey` bleibt für
|
||||
bestehende Aufrufer (u. a. `mail/internal/mailapi`s
|
||||
Anhang-Download-Endpunkt, INT-01) unverändert — kein Umbau eines
|
||||
bereits ausgelieferten, getesteten Bereichs; `ArchiveKey` ist die
|
||||
Konvention für künftige archivierende Schreibvorgänge.
|
||||
|
||||
`ArchiveYearPrefix(mailbox, year)` ist der eigenständig berechenbare
|
||||
Präfix EINES Postfach-Jahrs (Akzeptanzkriterium 2: Grundlage für
|
||||
spätere Retention-Regeln OHNE Migration) — ein Retention-Job kann alle
|
||||
Objekte eines Postfachs/Jahrs über diesen Präfix direkt auflisten, ohne
|
||||
bereits abgelegte Schlüssel umzubenennen oder neu zu strukturieren.
|
||||
|
||||
Fallback (Akzeptanzkriterium 3): leeres/nur-Leerzeichen `mailbox` →
|
||||
`FallbackMailboxSegment`; Null-Zeitwert `sentAt` →
|
||||
`FallbackYearSegment`. `ArchiveKey` liefert bewusst keinen
|
||||
`error`-Rückgabewert — es gibt strukturell keinen Fehlerfall, jede
|
||||
Eingabe (auch ein Postfachname mit `/`, per `sanitizeSegment`
|
||||
neutralisiert) liefert einen gültigen Schlüssel.
|
||||
|
||||
## Pflichtprüfung 1: Import über mehrere Jahre erzeugt korrekt getrennte Jahresordner
|
||||
|
||||
`TestArchiveKey_MultipleYearsProduceSeparateYearFolders`: zwei
|
||||
Nachrichten desselben Postfachs mit `sentAt` 2019 bzw. 2024 liefern
|
||||
Schlüssel unter `postfach/INBOX/2019/` bzw. `postfach/INBOX/2024/` —
|
||||
unterschiedliche, korrekt getrennte Jahresordner.
|
||||
|
||||
Ergebnis: **BESTANDEN**.
|
||||
|
||||
## Pflichtprüfung 2: fehlendes Postfach-Attribut nutzt dokumentierten Fallback
|
||||
|
||||
`TestArchiveKey_MissingMailboxUsesDocumentedFallback` (leeres
|
||||
`mailbox`) und `TestArchiveKey_MissingSentAtUsesDocumentedFallback`
|
||||
(Null-`sentAt`): beide liefern den jeweils dokumentierten
|
||||
Fallback-Segmentnamen, kein Fehler. `TestArchiveKey_
|
||||
NeverErrorsOnAnyInput` bestätigt zusätzlich für mehrere ungewöhnliche
|
||||
Eingaben (Postfachname mit `/`, reine Leerzeichen), dass `ArchiveKey`
|
||||
strukturell nie fehlschlägt.
|
||||
|
||||
Ergebnis: **BESTANDEN**.
|
||||
|
||||
## Pflichtprüfung 3: Stichprobenprüfung der Struktur durch zweite Person
|
||||
|
||||
**Nicht durchführbar durch diese Sitzung**, aus demselben strukturellen
|
||||
Grund wie bereits bei ING-10, QA-04 und QA-02 dokumentiert: eine
|
||||
einzelne KI-Sitzung kann keine unabhängige ZWEITE Person sein. **Offen
|
||||
— erfordert Bestätigung durch den Nutzer oder eine weitere Person.**
|
||||
Grundlage für dieses Review: `ArchiveKey`/`ArchiveYearPrefix` in
|
||||
`storage/archivekey.go`, fünf reale Tests in
|
||||
`storage/archivekey_test.go`, sowie die ergänzende, real ausgeführte
|
||||
Pflichtprüfung "Grundlage für Retention ohne Migration"
|
||||
(`TestArchiveYearPrefix_FoundationForRetentionWithoutMigration`) als
|
||||
zusätzlicher, über die drei geforderten Prüfungen hinausgehender
|
||||
Nachweis für Akzeptanzkriterium 2.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
1. **Ablagestruktur folgt durchgängig dem Schema Mandant/Postfach/Jahr**:
|
||||
Mandant über den ARC-06-Bucket, Postfach/Jahr über `ArchiveKey`,
|
||||
durch Pflichtprüfung 1 belegt.
|
||||
2. **Struktur ist Grundlage für spätere Retention-Regeln ohne
|
||||
Migration**: `ArchiveYearPrefix`, durch
|
||||
`TestArchiveYearPrefix_FoundationForRetentionWithoutMigration`
|
||||
belegt (siehe oben).
|
||||
3. **Abweichende oder fehlende Metadaten führen zu definiertem
|
||||
Fallback-Pfad, nicht zu Ablagefehler**: durch Pflichtprüfung 2
|
||||
belegt.
|
||||
|
||||
## Build/Vet/Lint/Test — Gesamtmodul
|
||||
|
||||
```
|
||||
go build ./... → OK
|
||||
go vet ./... → OK
|
||||
golangci-lint run ./... → 0 issues
|
||||
go test ./... -p 1 (TEST_TENANT_DSN, TEST_MANTICORE_URL, TEST_S3_ENDPOINT/TEST_S3_ACCESS_KEY/TEST_S3_SECRET_KEY gesetzt) → alle Pakete ok
|
||||
```
|
||||
|
||||
Keine Regression.
|
||||
|
||||
## Ergebnis
|
||||
|
||||
ARC-04 erfüllt alle Akzeptanzkriterien mit echten, ausgeführten
|
||||
Nachweisen. Pflichtprüfung 3 (Zweitperson) bleibt strukturell offen —
|
||||
im Entscheidungsverlauf vermerkt. Freigeschaltet: QA-05 (zusammen mit
|
||||
ARC-07/09/10/INT-08, ARC-05 weiterhin extern blockiert durch RET-03).
|
||||
@@ -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, "/", "_")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user