diff --git a/mail/cmd/mailpostfach-a11y-fixture/main.go b/mail/cmd/mailpostfach-a11y-fixture/main.go
new file mode 100644
index 00000000..67fd7628
--- /dev/null
+++ b/mail/cmd/mailpostfach-a11y-fixture/main.go
@@ -0,0 +1,242 @@
+// QA-08: startet den ECHTEN mailapi.Server (reale Routen, reale
+// moduleauth-Tokenprüfung) mit synthetischen Postfachdaten für den
+// Barrierefreiheits-Audit von web/mail-postfach. Eigenständig von
+// mailpostfach-fixture (UX-01, Mandantentrennungs-Fokus), da der
+// Browser-Audit zusätzlich CORS-Header und einen Platzhalter für den
+// Core-Modul-Token-Endpunkt braucht (kein echter NEXARCH-Core auf dem
+// Testhost verfügbar) — kein Produktivpfad.
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "gitea.perlbach24.de/scripte/nexarch/mail/internal/mailapi"
+ "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth"
+ "gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
+ "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage"
+)
+
+const tenant = "a11y-tenant"
+
+type memorySearch struct {
+ docs map[string]search.Document
+}
+
+func newMemorySearch() *memorySearch {
+ return &memorySearch{docs: map[string]search.Document{}}
+}
+
+func (m *memorySearch) put(doc search.Document) {
+ m.docs[doc.MessageID] = doc
+}
+
+func (m *memorySearch) Search(_ context.Context, tenantSlug, queryText string) ([]search.Result, error) {
+ if tenantSlug != tenant {
+ return nil, nil
+ }
+ var results []search.Result
+ for _, doc := range m.docs {
+ results = append(results, search.Result{MessageID: doc.MessageID, Subject: doc.Subject, SentAtUnixEpoch: doc.SentAtUnixEpoch, Sender: doc.Sender, Mailbox: doc.Mailbox})
+ }
+ return results, nil
+}
+
+func (m *memorySearch) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []search.FacetFilter) ([]search.Result, error) {
+ results, err := m.Search(ctx, tenantSlug, queryText)
+ if err != nil {
+ return nil, err
+ }
+ filtered := make([]search.Result, 0, len(results))
+ for _, res := range results {
+ match := true
+ for _, f := range filters {
+ if f.Field == search.FacetFieldMailbox && res.Mailbox != f.Value {
+ match = false
+ }
+ }
+ if match {
+ filtered = append(filtered, res)
+ }
+ }
+ return filtered, nil
+}
+
+func (m *memorySearch) GetByMessageID(_ context.Context, tenantSlug, messageID string) (search.Document, bool, error) {
+ if tenantSlug != tenant {
+ return search.Document{}, false, nil
+ }
+ doc, ok := m.docs[messageID]
+ return doc, ok, nil
+}
+
+func (m *memorySearch) ListByMailbox(_ context.Context, tenantSlug, mailbox string) ([]search.Document, error) {
+ if tenantSlug != tenant {
+ return nil, nil
+ }
+ var docs []search.Document
+ for _, doc := range m.docs {
+ if mailbox != "" && doc.Mailbox != mailbox {
+ continue
+ }
+ docs = append(docs, doc)
+ }
+ return docs, nil
+}
+
+func (m *memorySearch) Facets(_ context.Context, tenantSlug, _ string, _ []search.FacetFilter) (search.FacetResult, error) {
+ if tenantSlug != tenant {
+ return search.FacetResult{}, nil
+ }
+ counts := map[string]int64{}
+ for _, doc := range m.docs {
+ if doc.Mailbox != "" {
+ counts[doc.Mailbox]++
+ }
+ }
+ values := make([]search.FacetValue, 0, len(counts))
+ for mailbox, count := range counts {
+ values = append(values, search.FacetValue{Value: mailbox, Count: count})
+ }
+ return search.FacetResult{Values: map[string][]search.FacetValue{string(search.FacetFieldMailbox): values}}, nil
+}
+
+type noopUsageReporter struct{}
+
+func (noopUsageReporter) Report(context.Context, string, string, int64) error { return nil }
+
+type fixedStorageProvider struct {
+ service *storage.Service
+}
+
+func (p fixedStorageProvider) ServiceFor(tenantSlug string) (*storage.Service, error) {
+ if tenantSlug != tenant {
+ return nil, fmt.Errorf("a11y-fixture: unbekannter mandant %q", tenantSlug)
+ }
+ return p.service, nil
+}
+
+// seed deckt die Fälle ab, die die Postfach-Oberfläche real zeigen muss:
+// einen verschachtelten Ordner (Ordnerbaum-Auf-/Zuklappen), einen leeren
+// Ordner (Leerzustand) und genug Nachrichten, um Sortierung sichtbar zu
+// machen.
+func seed(sc *memorySearch) {
+ sc.put(search.Document{MessageID: "msg-rechnung-1", Subject: "Rechnung September 2026", Sender: "buchhaltung@beispiel.test", Mailbox: "posteingang", SentAtUnixEpoch: 1_700_000_300})
+ sc.put(search.Document{MessageID: "msg-rechnung-2", Subject: "Rechnung Oktober 2026", Sender: "zentrale@beispiel.test", Mailbox: "posteingang", SentAtUnixEpoch: 1_700_000_400})
+ sc.put(search.Document{MessageID: "msg-vertrag-1", Subject: "Vertragsunterlagen zur Unterschrift", Sender: "vertrieb@beispiel.test", Mailbox: "archiv", SentAtUnixEpoch: 1_700_000_200})
+ sc.put(search.Document{MessageID: "msg-projekt-1", Subject: "Projektplan Alpha", Sender: "pm@beispiel.test", Mailbox: "projekte/alpha", SentAtUnixEpoch: 1_700_000_100})
+ sc.put(search.Document{MessageID: "msg-projekt-2", Subject: "Statusbericht Beta", Sender: "pm@beispiel.test", Mailbox: "projekte/beta", SentAtUnixEpoch: 1_700_000_150})
+
+ // Genug Nachrichten, damit die Nachrichtenliste tatsaechlich ueberlaeuft
+ // (Grundlage fuer die Tastatur-Scroll-Pruefung, nicht nur Tab-Erreichbarkeit).
+ for i := 0; i < 20; i++ {
+ sc.put(search.Document{
+ MessageID: fmt.Sprintf("msg-liste-%d", i),
+ Subject: fmt.Sprintf("Nachricht %d", i),
+ Sender: fmt.Sprintf("absender%d@beispiel.test", i%5),
+ Mailbox: "posteingang",
+ SentAtUnixEpoch: 1_700_001_000 + int64(i),
+ })
+ }
+}
+
+func newStorageService() (*storage.Service, error) {
+ dir, err := os.MkdirTemp("", "qa08-a11y-fixture-storage-")
+ if err != nil {
+ return nil, err
+ }
+ return storage.NewService(storage.NewLocalDriver(dir), noopUsageReporter{}, tenant), nil
+}
+
+// corsForAudit reflektiert den anfragenden Origin mit Zugangsdaten-Flag,
+// da der Browser-Audit das Frontend und diese Fixture-API auf
+// unterschiedlichen Ports (= unterschiedlichen Origins) betreibt. Nur für
+// diesen Testaufbau nötig, der echte NEXARCH-Core läuft same-origin
+// hinter einem Reverse-Proxy.
+func corsForAudit(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ origin := r.Header.Get("Origin")
+ if origin != "" {
+ w.Header().Set("Access-Control-Allow-Origin", origin)
+ w.Header().Set("Access-Control-Allow-Credentials", "true")
+ w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept")
+ w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+ }
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func main() {
+ sc := newMemorySearch()
+ seed(sc)
+
+ svc, err := newStorageService()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "a11y-fixture: storage:", err)
+ os.Exit(1)
+ }
+ sp := fixedStorageProvider{service: svc}
+
+ srv := mailapi.NewServer(sc, sp)
+ mux := http.NewServeMux()
+ verifier, issuer := moduleauth.NewTestVerifierAndIssuer()
+ srv.RegisterRoutes(mux, verifier)
+
+ token := issuer.Token("a11y-user", tenant, "user", "mail", "")
+
+ // Platzhalter für den Core-Modul-Token-Endpunkt: web/mail-postfach holt
+ // sich darüber ein Bearer-Token (lib/http.ts, @nexarch/shl createSession).
+ // Kein echter NEXARCH-Core im Testaufbau, daher liefert dieser Stub das
+ // bereits ausgestellte, echt signierte Token direkt.
+ mux.HandleFunc("/auth/module-token", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "token": token,
+ "expires_in": 3600,
+ "tenant": tenant,
+ "role": "user",
+ })
+ })
+
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "a11y-fixture: listener:", err)
+ os.Exit(1)
+ }
+ port := listener.Addr().(*net.TCPAddr).Port
+
+ fmt.Println(mustJSON(map[string]any{
+ "port": port,
+ "tenant": tenant,
+ }))
+
+ httpSrv := &http.Server{Handler: corsForAudit(mux)}
+ go func() {
+ if err := httpSrv.Serve(listener); err != nil && err != http.ErrServerClosed {
+ fmt.Fprintln(os.Stderr, "a11y-fixture: serve:", err)
+ }
+ }()
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ <-ctx.Done()
+ _ = httpSrv.Close()
+}
+
+func mustJSON(v any) string {
+ raw, err := json.Marshal(v)
+ if err != nil {
+ panic(err)
+ }
+ return string(raw)
+}
diff --git a/mail/docs/QA-08-PRUEFPROTOKOLL.md b/mail/docs/QA-08-PRUEFPROTOKOLL.md
new file mode 100644
index 00000000..b2337dcd
--- /dev/null
+++ b/mail/docs/QA-08-PRUEFPROTOKOLL.md
@@ -0,0 +1,99 @@
+# QA-08 – Prüfprotokoll: Barrierefreiheits-Audit der Postfach-Oberfläche
+
+Stand: 2026-10-16. Entwicklungshost 192.168.1.204, Google Chrome (Puppeteer), axe-core aus dem bestehenden pa11y-Bestand. Branch `feature/qa-08-barrierefreiheits-audit`.
+
+## Basis dieses Audits
+
+`UX-01` (Postfach-/Ordneransicht, `web/mail-postfach`) ist zum Zeitpunkt dieses Audits **nicht** auf `main` gemergt, sondern liegt nur auf dem offenen, ungemergten Gitea-Remote-Branch `feature/ux-01-postfach-ordneransicht`. Da dieses Ticket explizit "der Postfach-Oberfläche" gilt, wurde direkt auf diesem Branch aufgebaut: `git fetch origin feature/ux-01-postfach-ordneransicht` + `git checkout -b feature/qa-08-barrierefreiheits-audit origin/feature/ux-01-postfach-ordneransicht`. Alle hier beschriebenen Funde und Fixes gelten für den UX-01-Code-Stand dieses Branches; sie müssen beim Mergen von UX-01 nach `main` mitgenommen werden (siehe "Offene Punkte" unten).
+
+Laut `mail/docs/UX-01-PRUEFPROTOKOLL.md` war Barrierefreiheit in UX-01 bewusst nur als Grundstruktur vorbereitet ("ARIA-Rollen (`tree`, `treeitem`, `listbox`, `option`) vorbereitet, kein vollständiger Audit") — dieses Ticket liefert den fehlenden vollständigen Audit nach.
+
+## Prüfaufbau (wiederverwendet aus `web/mail-search`, UX-07/UX-08)
+
+Gleiches Werkzeug wie bei allen anderen Modulen dieser Session: `web/a11y-audit/audit.js` (axe WCAG 2.0/2.1 A+AA, Tab-Erreichbarkeit, Fokusindikator-Prüfung, Layout/Kontrast), keine neue Prüflogik. Neu nur die modulspezifische Startinfrastruktur, nach dem Muster von `web/a11y-audit/mail-search-run.sh`:
+
+- `mail/cmd/mailpostfach-a11y-fixture` (neu, reine Testinfrastruktur, kein Produktivpfad): startet den echten `mailapi.Server` mit echter `moduleauth`-Signaturprüfung auf einem echten TCP-Port, synthetische Postfachdaten eines Mandanten (verschachtelter Ordner `projekte/alpha`/`projekte/beta` für den Ordnerbaum, 25 Nachrichten in `posteingang` damit die Nachrichtenliste tatsächlich überläuft — sonst wäre der unten gefundene Scroll-Bug nicht reproduzierbar). Zusätzlich zu den echten `mailapi`-Routen registriert die Fixture zwei testaufbau-spezifische Hilfen, die ein echter NEXARCH-Core auf dem Testhost ersetzen würde: einen Platzhalter-Endpunkt `/auth/module-token` (liefert das bereits echt signierte Testtoken, das `@nexarch/shl` `createSession` sonst vom Core holen würde — kein echter Core auf dem Testhost verfügbar) und CORS-Header (Frontend und Fixture-API laufen auf unterschiedlichen Ports/Origins, ein echtes Deployment hätte beides hinter demselben Reverse-Proxy).
+- `web/a11y-audit/mail-postfach-run.sh` (neu, nach dem Muster von `mail-search-run.sh`/`connect-status-run.sh`): baut die Fixture, baut und startet `web/mail-postfach` dagegen, führt `audit.js` und den Tastaturfluss aus.
+- `web/mail-postfach/keyboard-flow-check.mjs` (neu, nach dem Muster von `web/mail-search/keyboard-flow-check.mjs`, UX-08): echter Browserlauf der Tastaturbedienung. Anders als bei `mail-search` muss hier kein `fetch` gemockt werden — die Fixture liefert bereits echte Daten über eine echte, signierte Autorisierung.
+
+## Ergebnis auf einen Blick
+
+| Akzeptanzkriterium | Ergebnis |
+|---|---|
+| AK1: automatisierter Audit-Lauf zeigt keine kritischen Verstöße mehr | erfüllt (1 echter Fund, behoben, siehe unten) |
+| AK2: manuelle Stichprobe bestätigt sinnvolle Tastatur- und Screenreader-Bedienung | Tastatur: erfüllt (echter Browserlauf, 9/9 bestanden). Screenreader: nicht möglich, siehe Einschränkung unten |
+| AK3: Ergebnis dokumentiert und mit Verantwortlichen für Nachbesserung rückgekoppelt | erfüllt (dieses Protokoll + priorisierte offene Punkte unten) |
+
+## Vorher/Nachher-Vergleich: automatisierter axe-Lauf
+
+### Vorher (`OUT=/tmp/qa08-out-before web/a11y-audit/mail-postfach-run.sh`)
+
+| Schema | axe-Befund |
+|---|---|
+| hell | `scrollable-region-focusable` (serious, 1 Knoten) |
+| dunkel | `scrollable-region-focusable` (serious, 1 Knoten) |
+
+Exit-Code 1. Betroffener Knoten: `
` — die scrollbare Nachrichtenliste selbst war nicht per Tastatur fokussierbar (kein `tabIndex`), nur ihre virtualisiert gerenderten Einträge. Bei einem Ordner mit mehr Nachrichten, als in den sichtbaren Bereich passen, konnte ein reiner Tastaturnutzer den Inhalt nicht scrollen — weder per Tab (die Einträge selbst sind per Design nicht fokussierbar, siehe `role=option` ohne eigene Aktion) noch per Pfeiltaste (Container nicht fokussierbar). WCAG 2.1.1 (Tastatur), axe-Regel `scrollable-region-focusable` (`wcag2a`).
+
+### Fix
+
+`web/mail-postfach/components/MessageList.tsx`: `tabIndex={0}` auf dem Listbox-Container ergänzt. Damit ist der scrollbare Bereich selbst per Tab erreichbar, und Chrome scrollt einen fokussierten, scrollbaren Container nativ per Pfeiltaste/Bild-ab — keine eigene Scroll-Logik nötig.
+
+### Nachher (`OUT=/tmp/qa08-out-after web/a11y-audit/mail-postfach-run.sh`)
+
+| Schema | axe-Befund |
+|---|---|
+| hell | keine |
+| dunkel | keine |
+
+Exit-Code 0. Tab-Erreichbarkeit: 9/9 interaktive Elemente erreicht (vorher 8/8 — der jetzt fokussierbare Listbox-Container zählt als zusätzliches Element), kein fehlender Fokusindikator, keine Tastaturfalle.
+
+## Zweiter echter Fund (nicht von axe erkannt, über den Tastaturfluss gefunden, behoben)
+
+`web/mail-postfach/components/FolderTree.tsx`: die Ordnerbaum-Einträge (`role="treeitem"`, `tabIndex={0}`) hatten ausschließlich einen `onClick`-Handler. Ein `
` löst bei Tastaturaktivierung (Enter/Leertaste) anders als `