feat(PROJ-18): E-Mail Integritätsprüfung (SHA-256 Verifikation)

- Storage: VerifyIntegrity, GetAllIDs, GetVerifyStatus + DB-Spalten
- main: Hintergrund-Worker alle 5 Minuten (beim Start sofort: 40/40 OK)
- API: verify_ok + verified_at in GET /api/mails/{id} Antwort
- Frontend: Grüner Haken / graues X / rotes X in Mail-Ansicht

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-14 21:28:40 +01:00
parent 7e68c7ab02
commit 3c722d0987
7 changed files with 179 additions and 1 deletions
+13
View File
@@ -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,
})
}
+74
View File
@@ -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
}