package storage import ( "context" "fmt" "io/fs" "log/slog" "os" "path/filepath" ) // RecompressStats holds counters from a Recompress run. type RecompressStats struct { Total int Compressed int // files that were compressed in this run AlreadyCompressed int // files already compressed, skipped Errors int // files skipped due to errors BytesSaved int64 // total bytes saved (original − compressed on disk) } // Recompress walks the store directory and gzip-compresses any mail file that // is not yet compressed. Files are replaced atomically via a temp file + rename. // Already-compressed files (magic byte 0x01) are skipped. // If dryRun is true, no files are written but stats are calculated. func (s *Store) Recompress(ctx context.Context, dryRun bool, logger *slog.Logger) (RecompressStats, error) { storeDir := filepath.Join(s.dir, "store") var stats RecompressStats err := filepath.WalkDir(storeDir, func(path string, d fs.DirEntry, werr error) error { if werr != nil { return werr } if d.IsDir() { return nil } select { case <-ctx.Done(): return ctx.Err() default: } stats.Total++ id := d.Name() // Read raw file bytes (encrypted or not) raw, err := os.ReadFile(path) if err != nil { logger.Warn("recompress: read failed", "id", id, "err", err) stats.Errors++ return nil } // Decrypt to get plaintext plaintext := raw if s.key != nil { pt, err := s.decrypt(raw) if err != nil { // Pre-encryption file — treat raw bytes as plaintext pt = raw } plaintext = pt } // Already compressed? if len(plaintext) > 0 && plaintext[0] == magicGzip { stats.AlreadyCompressed++ if stats.AlreadyCompressed%1000 == 0 { logger.Info("recompress: progress", "total", stats.Total, "already_compressed", stats.AlreadyCompressed) } return nil } // Compress compressed, err := compressGzip(plaintext) if err != nil { logger.Warn("recompress: compress failed", "id", id, "err", err) stats.Errors++ return nil } // Skip if compression doesn't help if len(compressed) >= len(plaintext) { stats.AlreadyCompressed++ return nil } bytesSaved := int64(len(raw)) - int64(len(compressed)) if s.key != nil { // After encryption the size changes; estimate savings from plaintext difference bytesSaved = int64(len(plaintext)) - int64(len(compressed)) } if dryRun { stats.Compressed++ stats.BytesSaved += bytesSaved return nil } // Encrypt compressed content toWrite := compressed if s.key != nil { enc, err := s.encrypt(compressed) if err != nil { logger.Warn("recompress: encrypt failed", "id", id, "err", err) stats.Errors++ return nil } toWrite = enc } // Atomic write: temp file in same directory, then rename dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".recompress-*") if err != nil { logger.Warn("recompress: temp file failed", "id", id, "err", err) stats.Errors++ return nil } tmpName := tmp.Name() if _, err := tmp.Write(toWrite); err != nil { _ = tmp.Close() _ = os.Remove(tmpName) logger.Warn("recompress: write temp failed", "id", id, "err", err) stats.Errors++ return nil } if err := tmp.Close(); err != nil { _ = os.Remove(tmpName) logger.Warn("recompress: close temp failed", "id", id, "err", err) stats.Errors++ return nil } if err := os.Rename(tmpName, path); err != nil { _ = os.Remove(tmpName) logger.Warn("recompress: rename failed", "id", id, "err", err) stats.Errors++ return nil } // Update storage_objects record if DB available if s.db != nil { origSize := int64(len(plaintext)) compSize := int64(len(toWrite)) _, _ = s.db.Exec(ctx, ` INSERT INTO storage_objects (storage_type, path, compression, size_original, size_compressed, checksum) VALUES ('filesystem', $1, 'gzip', $2, $3, $4) ON CONFLICT DO NOTHING `, path, origSize, compSize, id) // Also update emails.storage_id if the record exists but storage_id is null _, _ = s.db.Exec(ctx, ` UPDATE emails SET storage_id = ( SELECT id FROM storage_objects WHERE checksum = $1 LIMIT 1 ) WHERE id = $1 AND storage_id IS NULL `, id) } stats.Compressed++ stats.BytesSaved += bytesSaved if stats.Total%500 == 0 { logger.Info("recompress: progress", "total", stats.Total, "compressed", stats.Compressed, "already_compressed", stats.AlreadyCompressed, "bytes_saved", fmt.Sprintf("%.1f MB", float64(stats.BytesSaved)/1024/1024), ) } return nil }) if err != nil { return stats, fmt.Errorf("recompress: walk: %w", err) } return stats, nil }