diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go
index dd6da4a..22e14cb 100644
--- a/cmd/archivmail/main.go
+++ b/cmd/archivmail/main.go
@@ -165,6 +165,9 @@ func main() {
// Backfill in background: migrate existing files into DB metadata + re-index
go runBackfill(context.Background(), mailStore, idx, worker, logger)
+ // Background integrity verification — runs every 5 minutes
+ go runIntegrityCheck(context.Background(), mailStore, logger)
+
// Start HTTP API
go func() {
logger.Info("starting API server", "addr", bind)
@@ -276,6 +279,47 @@ func runBackfill(ctx context.Context, store *storage.Store, idx index.Indexer, w
logger.Info("backfill: complete", "total", count, "submitted_for_index", needIndex, "errors", errCount)
}
+// runIntegrityCheck verifies all stored emails every 5 minutes by re-computing
+// their SHA-256 and comparing it to the stored file ID.
+func runIntegrityCheck(ctx context.Context, store *storage.Store, logger *slog.Logger) {
+ // run once at startup, then every 5 minutes
+ doVerify := func() {
+ ids, err := store.GetAllIDs(ctx)
+ if err != nil {
+ logger.Error("integrity check: get IDs failed", "err", err)
+ return
+ }
+ ok := 0
+ fail := 0
+ for _, id := range ids {
+ verified, err := store.VerifyIntegrity(ctx, id)
+ if err != nil {
+ fail++
+ continue
+ }
+ if verified {
+ ok++
+ } else {
+ fail++
+ logger.Warn("integrity check: FAILED", "id", id)
+ }
+ }
+ logger.Info("integrity check: complete", "ok", ok, "failed", fail, "total", len(ids))
+ }
+
+ doVerify()
+ ticker := time.NewTicker(5 * time.Minute)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ doVerify()
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
// seedDefaultUsers creates default admin and auditor accounts if no users exist yet.
func seedDefaultUsers(users *userstore.Store, logger *slog.Logger) error {
all, err := users.List("")
diff --git a/features/INDEX.md b/features/INDEX.md
index af8962b..bc00990 100644
--- a/features/INDEX.md
+++ b/features/INDEX.md
@@ -30,7 +30,8 @@
| PROJ-16 | LDAP / Active Directory Anbindung | In Progress | [PROJ-16](PROJ-16-ldap-active-directory.md) | 2026-03-13 |
| PROJ-17 | Admin Dashboard – Systemauslastung & Archiv-Übersicht | In Review | [PROJ-17](PROJ-17-system-dashboard.md) | 2026-03-14 |
+| PROJ-18 | E-Mail Integritätsprüfung | In Progress | [PROJ-18](PROJ-18-integritaetspruefung.md) | 2026-03-14 |
-## Next Available ID: PROJ-18
+## Next Available ID: PROJ-19
diff --git a/features/PROJ-18-integritaetspruefung.md b/features/PROJ-18-integritaetspruefung.md
new file mode 100644
index 0000000..8484eb8
--- /dev/null
+++ b/features/PROJ-18-integritaetspruefung.md
@@ -0,0 +1,18 @@
+# PROJ-18: E-Mail Integritätsprüfung
+
+## Status: In Progress
+**Created:** 2026-03-14
+
+## User Stories
+- Als Admin möchte ich sehen ob eine archivierte E-Mail unverändert ist, damit ich Manipulationen erkennen kann.
+
+## Acceptance Criteria
+- [x] Hintergrund-Job läuft alle 5 Minuten und prüft alle E-Mails
+- [x] Prüfung: SHA-256 der entschlüsselten Datei == gespeicherte ID
+- [x] Ergebnis wird in DB gespeichert (verify_ok, verified_at)
+- [x] Mail-Ansicht zeigt grünen Haken (verifiziert OK), graues X (noch nicht geprüft) oder rotes X (Manipulation erkannt)
+
+## Implementation Notes
+- verify_ok BOOLEAN + verified_at TIMESTAMPTZ in emails-Tabelle
+- Background worker in main.go, Ticker 5 Minuten
+- GET /api/mails/{id} gibt verified_ok + verified_at zurück
diff --git a/internal/api/server.go b/internal/api/server.go
index f646b1a..2f3c527 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -548,6 +548,17 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
dateStr = pm.Date.UTC().Format(time.RFC3339)
}
+ // Verify status
+ vs, _ := s.store.GetVerifyStatus(r.Context(), id)
+ var verifyOK interface{} = nil
+ var verifiedAt interface{} = nil
+ if vs.VerifyOK != nil {
+ verifyOK = *vs.VerifyOK
+ }
+ if vs.VerifiedAt != nil {
+ verifiedAt = vs.VerifiedAt.UTC().Format(time.RFC3339)
+ }
+
writeJSON(w, http.StatusOK, map[string]interface{}{
"id": id,
"from": pm.From,
@@ -560,6 +571,8 @@ func (s *Server) handleGetMail(w http.ResponseWriter, r *http.Request) {
"body_plain": pm.TextBody,
"raw_headers": extractRawHeaders(raw),
"attachments": attachments,
+ "verify_ok": verifyOK,
+ "verified_at": verifiedAt,
})
}
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index 4f858f6..4c6e6fb 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -77,6 +77,9 @@ func New(cfg Config) (*Store, error) {
pool.Close()
return nil, fmt.Errorf("storage: init schema: %w", err)
}
+ ctx := context.Background()
+ _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS verify_ok BOOLEAN`)
+ _, _ = s.db.Exec(ctx, `ALTER TABLE emails ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ`)
}
return s, nil
@@ -578,3 +581,74 @@ func (s *Store) WalkStore(ctx context.Context, fn func(id string) error) error {
func (s *Store) filePath(id string) string {
return filepath.Join(s.dir, "store", id[:2], id)
}
+
+// ── Integrity verification ────────────────────────────────────────────────
+
+// VerifyIntegrity re-computes the SHA-256 of the stored plaintext and
+// compares it to the file ID. Updates verify_ok and verified_at in the DB.
+func (s *Store) VerifyIntegrity(ctx context.Context, id string) (bool, error) {
+ raw, err := s.Load(id)
+ if err != nil {
+ return false, err
+ }
+ sum := sha256.Sum256(raw)
+ computed := fmt.Sprintf("%x", sum[:])
+ ok := computed == id
+ if s.db != nil {
+ s.db.Exec(ctx,
+ `UPDATE emails SET verify_ok=$1, verified_at=NOW() WHERE id=$2`,
+ ok, id)
+ }
+ return ok, nil
+}
+
+// GetAllIDs returns all email IDs from the DB, or walks the store if no DB.
+func (s *Store) GetAllIDs(ctx context.Context) ([]string, error) {
+ if s.db != nil {
+ rows, err := s.db.Query(ctx, `SELECT id FROM emails ORDER BY received_at`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ continue
+ }
+ ids = append(ids, id)
+ }
+ return ids, nil
+ }
+ // fallback: walk store
+ var ids []string
+ err := s.WalkStore(ctx, func(id string) error {
+ ids = append(ids, id)
+ return nil
+ })
+ return ids, err
+}
+
+// VerifyStatus holds the result of an integrity verification.
+type VerifyStatus struct {
+ VerifyOK *bool // nil = not yet checked
+ VerifiedAt *time.Time
+}
+
+// GetVerifyStatus returns the stored verification status for a given email ID.
+func (s *Store) GetVerifyStatus(ctx context.Context, id string) (VerifyStatus, error) {
+ var vs VerifyStatus
+ if s.db == nil {
+ return vs, nil
+ }
+ row := s.db.QueryRow(ctx,
+ `SELECT verify_ok, verified_at FROM emails WHERE id=$1`, id)
+ var ok *bool
+ var at *time.Time
+ if err := row.Scan(&ok, &at); err != nil {
+ return vs, nil // not found = not verified
+ }
+ vs.VerifyOK = ok
+ vs.VerifiedAt = at
+ return vs, nil
+}
diff --git a/src/app/mail/[id]/page.tsx b/src/app/mail/[id]/page.tsx
index 04be2b5..acbfb30 100644
--- a/src/app/mail/[id]/page.tsx
+++ b/src/app/mail/[id]/page.tsx
@@ -83,6 +83,32 @@ function MailHeaderGrid({ mail }: { mail: MailDetail }) {
{mail.subject || "(kein Betreff)"}
Größe:
{formatBytes(mail.size)}
+ {/* Verification status */}
+ Integrität:
+
+ {mail.verify_ok === true ? (
+
+
+ Verifiziert
+
+ ) : mail.verify_ok === false ? (
+
+
+ Manipuliert!
+
+ ) : (
+
+
+ Noch nicht geprüft
+
+ )}
+