Files
sysopsandClaude Sonnet 5 1dfd8c18ac feat(PROJ-84): fix-subjects CLI-Backfill für kaputte MIME-Header-Betreffs
CLI-Subkommando "archivmail fix-subjects" korrigiert Bestandsmails mit
undekodiertem RFC-2047-Encoded-Word im Betreff (Folge des Fixes in
31d7113). Dry-Run per Default, echte Änderung nur mit --apply.

Original-EML im Storage bleibt unangetastet - nur Postgres emails.subject
wird korrigiert, danach Manticore-Reindex inkl. Erhalt von OCR-Text.
Jeder Lauf erzeugt einen Audit-Log-Eintrag (metadata_backfill).

Verifiziert auf 192.168.1.132: 543 Kandidaten, 541 reparierbar, 2 zu
Recht übersprungen (RFC-2047-Verletzung im Original). --apply noch
nicht ausgeführt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WapWkrQusDuBMhaN8WyuXB
2026-08-06 19:58:41 +02:00

75 lines
2.4 KiB
Go

package storage
import (
"context"
"fmt"
)
// SubjectRow is a minimal projection of an email used by metadata repair runs
// (e.g. the fix-subjects backfill). It never carries mail content — the
// encrypted original in the store is not touched by such runs.
type SubjectRow struct {
ID string
TenantID *int64
Subject string
}
// ListRawEncodedSubjects returns emails whose subject still contains an
// RFC 2047 encoded-word pattern (`=?charset?B|Q?...?=`). The SQL LIKE is only
// a cheap prefilter; the caller must verify with mailparser.HasEncodedWord and
// decide whether decoding actually yields a different value.
//
// tenantID nil = all tenants. limit <= 0 = no limit.
func (s *Store) ListRawEncodedSubjects(ctx context.Context, tenantID *int64, limit int) ([]SubjectRow, error) {
if s.db == nil {
return nil, fmt.Errorf("storage: list raw encoded subjects: no database configured")
}
query := `SELECT id, tenant_id, COALESCE(subject, '')
FROM emails
WHERE subject LIKE '%=?%?=%'`
args := []interface{}{}
if tenantID != nil {
args = append(args, *tenantID)
query += fmt.Sprintf(" AND tenant_id = $%d", len(args))
}
query += " ORDER BY received_at ASC"
if limit > 0 {
args = append(args, limit)
query += fmt.Sprintf(" LIMIT $%d", len(args))
}
rows, err := s.db.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("storage: list raw encoded subjects: %w", err)
}
defer rows.Close()
var out []SubjectRow
for rows.Next() {
var r SubjectRow
if err := rows.Scan(&r.ID, &r.TenantID, &r.Subject); err != nil {
return nil, fmt.Errorf("storage: list raw encoded subjects: scan: %w", err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("storage: list raw encoded subjects: rows: %w", err)
}
return out, nil
}
// UpdateSubjectMetadata rewrites only the `subject` metadata column of an
// email. The archived original (encrypted EML in the store) is deliberately
// left untouched — this is a display/search metadata repair, not a change to
// the immutable archive copy.
func (s *Store) UpdateSubjectMetadata(ctx context.Context, id, subject string) error {
if s.db == nil {
return fmt.Errorf("storage: update subject metadata: no database configured")
}
if _, err := s.db.Exec(ctx, `UPDATE emails SET subject = $1 WHERE id = $2`, subject, id); err != nil {
return fmt.Errorf("storage: update subject metadata %s: %w", id, err)
}
return nil
}